""" 真实模型评测的凭据边界、调用限额及用量可信度。 """ import json import os from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace from uuid import uuid4 import pytest from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langchain_core.messages import AIMessage from pydantic import Field from scripts.evaluation.models import ModelCallLimitError, ModelSettings, ModelUsageTracker, load_codex_model_settings def _config(tmp_path, credential='experimental_bearer_token = "private-test-key"'): """ 创建仅用于测试的用户明确配置,不读取任何真实凭据。 """ path = tmp_path / "config.toml" path.write_text('model = "test-model"\nmodel_provider = "selected"\nmodel_reasoning_effort = "high"\n' '[model_providers.selected]\nbase_url = "https://model.invalid/v1"\nwire_api = "responses"\n' + credential + '\n', encoding="utf-8") return path def test_model_settings_do_not_expose_explicit_credential(tmp_path): """ 凭据只用于连接,repr 和用户可见元数据不得包含它。 """ settings = load_codex_model_settings(_config(tmp_path)) assert settings.api_key == "private-test-key" assert "private-test-key" not in repr(settings) assert "private-test-key" not in json.dumps(settings.public_metadata()) assert settings.model == "test-model" assert settings.wire_api == "responses" def test_chat_completions_provider_is_supported(tmp_path): """ Google 等 OpenAI 兼容 provider 可以明确选择 Chat Completions 协议。 """ path = _config(tmp_path).read_text(encoding="utf-8").replace('wire_api = "responses"', 'wire_api = "chat_completions"') config = tmp_path / "chat-completions.toml" config.write_text(path, encoding="utf-8") settings = load_codex_model_settings(config) assert settings.wire_api == "chat_completions" assert settings.public_metadata()["wire_api"] == "chat_completions" def test_native_probe_accepts_oauth_only_provider_without_endpoint_or_key(tmp_path): """ 原生探针不发模型请求,因此可以检查仅有 OAuth 的官方 provider。 """ path = tmp_path / "oauth-only.toml" path.write_text( 'model = "gpt-test"\nmodel_provider = "selected"\n' '[model_providers.selected]\nwire_api = "responses"\nrequires_openai_auth = true\n', encoding="utf-8", ) settings = load_codex_model_settings(path, probe_only=True) assert settings.base_url == "https://evaluation-probe.invalid/v1" assert settings.api_key == "evaluation-probe-only" def test_real_run_still_rejects_provider_without_explicit_endpoint_or_key(tmp_path): """ 真实评测不能把本地登录态或探针占位值当成供应商凭据。 """ path = tmp_path / "oauth-only.toml" path.write_text( 'model = "gpt-test"\nmodel_provider = "selected"\n' '[model_providers.selected]\nwire_api = "responses"\nrequires_openai_auth = true\n', encoding="utf-8", ) with pytest.raises(ValueError, match="endpoint"): load_codex_model_settings(path) def test_codex_oauth_is_opt_in_and_keeps_token_out_of_public_metadata(tmp_path, monkeypatch): """ 配对评测显式启用本机 OAuth 后只向控制进程传递令牌和账户头。 """ path = tmp_path / "oauth-only.toml" path.write_text( 'model = "gpt-test"\nmodel_provider = "selected"\n' '[model_providers.selected]\nwire_api = "responses"\nrequires_openai_auth = true\n', encoding="utf-8", ) codex_home = tmp_path / "codex" codex_home.mkdir() codex_home.joinpath("auth.json").write_text(json.dumps({ "tokens": {"access_token": "oauth-private-token", "account_id": "acct-private"}, }), encoding="utf-8") monkeypatch.setenv("CODEX_HOME", os.fspath(codex_home)) settings = load_codex_model_settings(path, use_codex_auth=True) assert settings.auth_mode == "codex_oauth" assert settings.base_url == "https://chatgpt.com/backend-api/codex" assert settings.api_key == "oauth-private-token" assert settings.account_id == "acct-private" assert "oauth-private-token" not in repr(settings) assert "acct-private" not in repr(settings) assert "oauth-private-token" not in json.dumps(settings.public_metadata()) assert "acct-private" not in json.dumps(settings.public_metadata()) def test_only_named_provider_environment_key_is_used(tmp_path, monkeypatch): """ 仅使用配置明确指定的环境变量,不回退到其他账户或服务的凭据。 """ path = _config(tmp_path, 'env_key = "MOVIEPILOT_EVALUATION_TEST_KEY"') monkeypatch.setenv("MOVIEPILOT_EVALUATION_TEST_KEY", "named-key") assert load_codex_model_settings(path).api_key == "named-key" monkeypatch.delenv("MOVIEPILOT_EVALUATION_TEST_KEY") monkeypatch.setenv("OPENAI_API_KEY", "unrelated-key") with pytest.raises(ValueError, match="显式评测凭据"): load_codex_model_settings(path) @pytest.mark.parametrize("budget", [0, -1, 65]) def test_invalid_call_budget_is_rejected_before_live_request(tmp_path, budget): """ 无效预算不能退化成无限调用。 """ with pytest.raises(ValueError, match="预算"): load_codex_model_settings(_config(tmp_path), max_model_calls=budget) def test_parallel_model_calls_cannot_exceed_shared_limit(): """ 父模型和多个子模型并发开始时,也必须共享同一个硬限额。 """ tracker = ModelUsageTracker(3) def start(): """ 模拟一次尚未发出 HTTP 的模型调用起点。 """ try: tracker.on_chat_model_start({}, [], run_id=uuid4()) except ModelCallLimitError: return False return True with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(lambda _: start(), range(8))) assert sum(results) == 3 assert tracker.snapshot()["model_calls"] == 3 assert tracker.snapshot()["blocked_model_calls"] == 5 assert tracker.raise_error is True def test_usage_includes_all_calls_and_marks_unknown_usage(): """ 失败或没有 usage 的请求不能被报告成完整的零成本运行。 """ tracker = ModelUsageTracker(4) first, second = uuid4(), uuid4() tracker.on_chat_model_start({}, [], run_id=first) tracker.on_chat_model_start({}, [], run_id=second) usage = {"input_tokens": 12, "output_tokens": 8, "total_tokens": 20} response = SimpleNamespace(generations=[[SimpleNamespace(message=SimpleNamespace( usage_metadata=usage, response_metadata={"model_name": "reported-model"}, ))]]) tracker.on_llm_end(response, run_id=first) usage["total_tokens"] = 999 tracker.on_llm_error(RuntimeError("private error text"), run_id=second) snapshot = tracker.snapshot() assert snapshot["model_calls"] == 2 assert snapshot["tokens"]["total_tokens"] == 20 assert snapshot["usage_complete"] is False assert snapshot["reported_models"] == ["reported-model"] assert "private error text" not in json.dumps(snapshot) def test_empty_or_partial_usage_does_not_break_response_handling(): """ 统计器不能因不完整响应而打断本来可继续处理的模型结果。 """ tracker = ModelUsageTracker(2) run_id = uuid4() tracker.on_chat_model_start({}, [], run_id=run_id) tracker.on_llm_end(SimpleNamespace(generations=[]), run_id=run_id) snapshot = tracker.snapshot() assert snapshot["completed_model_calls"] == 1 assert snapshot["tokens"] is None assert snapshot["usage_complete"] is False @pytest.mark.parametrize(("field", "value"), [ ("model", ""), ("model", " "), ("model", 42), ("base_url", "file:///tmp/model"), ("base_url", "https:///missing-host"), ("base_url", "https://user:password@model.invalid/v1"), ("base_url", "https://model.invalid/v1?api_key=private-test-key"), ("base_url", "https://model.invalid/v1#private-test-key"), ("api_key", ""), ("api_key", " "), ("api_key", "private\ntest"), ("api_key", False), ("reasoning_effort", "unbounded"), ("wire_api", "unknown"), ("max_model_calls", 0), ("max_model_calls", 65), ("max_model_calls", True), ("max_model_calls", 2.5), ("max_output_tokens", 255), ("max_output_tokens", 32769), ("max_output_tokens", True), ("timeout_seconds", 29), ("timeout_seconds", 901), ("timeout_seconds", False), ("context_window", 0), ("context_window", -1), ("context_window", True), ]) def test_direct_settings_reject_invalid_worker_input(field, value): """ stdin 直接构造设置也必须执行同一校验,布尔值不能冒充整数预算。 """ values = {"model": "test-model", "base_url": "https://model.invalid/v1", "api_key": "private-test-key"} values[field] = value with pytest.raises(ValueError): ModelSettings(**values) @pytest.mark.parametrize("credential", [ 'experimental_bearer_token = ""', 'experimental_bearer_token = " "', 'experimental_bearer_token = "line\\nfeed"', 'experimental_bearer_token = false', ]) def test_invalid_explicit_credential_does_not_fallback_to_other_provider(tmp_path, monkeypatch, credential): """ 无效的选中凭据应明确失败,不能改用环境内其他账户的密钥。 """ monkeypatch.setenv("OPENAI_API_KEY", "unrelated-account-secret") with pytest.raises(ValueError) as failure: load_codex_model_settings(_config(tmp_path, credential)) assert "unrelated-account-secret" not in str(failure.value) def test_duplicate_callbacks_preserve_single_usage_record(): """ 相同 run_id 的重复开始与完成通知不能重复耗用预算或累计 token。 """ tracker = ModelUsageTracker(1) run_id = uuid4() tracker.on_chat_model_start({}, [], run_id=run_id) tracker.on_chat_model_start({}, [], run_id=run_id) response = SimpleNamespace(generations=[[SimpleNamespace(message=SimpleNamespace( usage_metadata={"input_tokens": 12, "output_tokens": 8, "total_tokens": 20}, response_metadata={}, ))]]) tracker.on_llm_end(response, run_id=run_id) tracker.on_llm_end(response, run_id=run_id) snapshot = tracker.snapshot() assert snapshot["model_calls"] == snapshot["completed_model_calls"] == 1 assert snapshot["tokens"]["total_tokens"] == 20 assert snapshot["blocked_model_calls"] == 0 @pytest.mark.parametrize("usage", [ {}, {"input_tokens": 10}, {"input_tokens": True, "output_tokens": 2, "total_tokens": 3}, {"input_tokens": -1, "output_tokens": 2, "total_tokens": 1}, {"input_tokens": 1.5, "output_tokens": 2, "total_tokens": 3.5}, ]) def test_invalid_usage_remains_unknown(usage): """ 不完整或类型错误的 usage 不能出现在完整成本报告里。 """ tracker = ModelUsageTracker(1) run_id = uuid4() tracker.on_chat_model_start({}, [], run_id=run_id) tracker.on_llm_end(SimpleNamespace(generations=[[SimpleNamespace(message=SimpleNamespace( usage_metadata=usage, response_metadata={}, ))]]), run_id=run_id) assert tracker.snapshot()["tokens"] is None assert tracker.snapshot()["usage_complete"] is False class _CountingModel(FakeMessagesListChatModel): """ 在实际 LangChain 调用中验证 callback 限额确实早于模型执行。 """ executed: int = Field(default=0) async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): """ 记录真正到达模型实现的请求数,不模拟 callback 调度。 """ self.executed += 1 return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs) @pytest.mark.asyncio async def test_sdk_call_limit_prevents_next_model_execution(): """ SDK 必须传播限额异常,第二次调用不能到达模型实现。 """ tracker = ModelUsageTracker(1) model = _CountingModel(responses=[AIMessage(content="离线结果")], callbacks=[tracker]) assert (await model.ainvoke("第一次请求")).content == "离线结果" with pytest.raises(ModelCallLimitError, match="上限"): await model.ainvoke("第二次请求") assert model.executed == 1 assert tracker.snapshot()["model_calls"] == 1 assert tracker.snapshot()["completed_model_calls"] == 1 assert tracker.snapshot()["blocked_model_calls"] == 1 def test_failed_request_records_type_status_without_private_error(): """ HTTP 失败可诊断为类型和状态码,私有请求内容不能进入公开统计。 """ tracker = ModelUsageTracker(1) run_id = uuid4() tracker.on_chat_model_start({}, [], run_id=run_id) error = RuntimeError("Bearer private-test-key; https://private-host.invalid/secret-path") error.status_code = 400 tracker.on_llm_error(error, run_id=run_id) snapshot = tracker.snapshot() assert snapshot["completed_model_calls"] == 0 assert snapshot["errors"] == [{"error_type": "RuntimeError", "http_status": 400}] assert snapshot["tokens"] is None assert "private" not in json.dumps(snapshot)