File size: 14,447 Bytes
154ef58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41859ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154ef58
 
 
 
 
 
 
 
 
 
 
 
 
 
41859ae
 
 
 
 
 
154ef58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41859ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f44e183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154ef58
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
"""模型列表与模型解析的验证。

核心诉求:不再使用 gemini-webapi 已废弃的硬编码 ``Model`` 枚举,而是读账号初始化时
真实探测到的模型(``client.list_models()`` / ``client.resolve_model()``)。

为了真实地验证集成路径,这里给**真实的** ``GeminiClient`` 注入 ``_model_registry``,
再让它去做 list/resolve,不联网。
"""

import os
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

os.environ["SECURE_1PSID"] = "PSID-MODEL"
os.environ["SECURE_1PSIDTS"] = "TS-MODEL"
os.environ["GEMINI_COOKIE_PATH"] = tempfile.mkdtemp(prefix="gs-model-")
os.environ["SESSION_HEALTH_INTERVAL"] = "3600"

from gemini_webapi import GeminiClient as RealGeminiClient  # noqa: E402
from gemini_webapi.constants import Model  # noqa: E402
from gemini_webapi.types import AvailableModel  # noqa: E402

import gemini_session as gs  # noqa: E402

PASS, FAIL = [], []


def check(name, cond, extra=""):
	(PASS if cond else FAIL).append(name)
	print(("[OK]   " if cond else "[FAIL] ") + name + (f"  {extra}" if extra and not cond else ""))


# 模拟账号真实探测到的模型:注意名字带版本号,且有一个不可选用
ACCOUNT_MODELS = [
	AvailableModel(
		model_id="9d8ca3786ebdfbea",
		model_name="gemini-3-pro",
		display_name="Gemini 3 Pro",
		description="最强推理模型",
		capacity=1,
		capacity_field=12,
		model_number=3,
		aliases=["3-pro", "gemini-3-pro-preview"],
	),
	AvailableModel(
		model_id="fbb127bbb056c959",
		model_name="gemini-3-flash",
		display_name="Gemini 3 Flash",
		description="速度与质量兼顾",
		capacity=1,
		capacity_field=12,
		model_number=1,
		aliases=["3-flash"],
	),
	AvailableModel(
		model_id="cf41b0e0dd7d53e5",
		model_name="gemini-3-flash-lite",
		display_name="Gemini 3 Flash Lite",
		description="最轻量",
		capacity=1,
		capacity_field=12,
		model_number=6,
		is_available=False,
	),
]


class FakeCookies:
	def __init__(self, values):
		self.jar = [SimpleNamespace(name=k, value=v) for k, v in values.items()]

	def get(self, name, default=None):
		for c in self.jar:
			if c.name == name:
				return c.value
		return default


class FakeResponse:
	def __init__(self):
		self.text = "OK"
		self.thoughts = None
		self.images = []
		self.metadata = ["cid-model"]


class FakeClient:
	"""模型相关能力直接委托给真实的 GeminiClient,其余用假实现避免联网。"""

	def __init__(self, psid=None, psidts=None, **kwargs):
		self.psid = psid
		self.psidts = psidts
		self._running = False
		self.client = None
		self.auto_refresh = False
		self.refresh_task = None
		self.activity_task = None
		self.account_status = SimpleNamespace(name="AVAILABLE")
		self.cookies = FakeCookies({"__Secure-1PSID": psid, "__Secure-1PSIDTS": psidts})
		self.generate_calls = []

		self._real = RealGeminiClient()
		self._real._model_registry = {m.model_id: m for m in ACCOUNT_MODELS}

	def list_models(self):
		return self._real.list_models()

	def resolve_model(self, name):
		return self._real.resolve_model(name)

	async def init(self, **kwargs):
		self._running = True
		self.client = object()
		self.auto_refresh = kwargs.get("auto_refresh", False)
		if self.auto_refresh:
			import asyncio

			self.refresh_task = asyncio.create_task(self.start_auto_refresh())
			self.activity_task = asyncio.create_task(self.start_activity_watchdog())

	async def generate_content(self, prompt, **kwargs):
		self.generate_calls.append(kwargs)
		return FakeResponse()

	async def generate_content_stream(self, prompt, **kwargs):
		self.generate_calls.append(kwargs)
		yield FakeResponse()

	async def fetch_latest_chat_response(self, cid):
		return FakeResponse()

	async def delete_chat(self, cid):
		return None

	async def start_auto_refresh(self):
		import asyncio

		while self._running:
			await asyncio.sleep(3600)

	async def start_activity_watchdog(self):
		import asyncio

		while self._running:
			await asyncio.sleep(3600)

	async def close(self):
		self._running = False
		self.client = None


def main():
	gs.GeminiClient = FakeClient

	import main as app_main
	from fastapi.testclient import TestClient

	# ---------------------------------------------------------- 纯函数层
	probe = FakeClient()
	check("1a 解析 model_name 命中", app_main.resolve_model_for_client(probe, "gemini-3-pro").model_name == "gemini-3-pro")
	check("1b 解析 display_name 命中", app_main.resolve_model_for_client(probe, "Gemini 3 Flash").model_name == "gemini-3-flash")
	check("1c 解析别名命中", app_main.resolve_model_for_client(probe, "3-flash").model_name == "gemini-3-flash")
	check("1d 解析 model_id 命中", app_main.resolve_model_for_client(probe, "9d8ca3786ebdfbea").model_name == "gemini-3-pro")

	unknown = app_main.resolve_model_for_client(probe, "totally-unknown-model-xyz")
	check("2a 完全未知的模型返回 None(不再回退到废弃枚举)", unknown is None, repr(unknown))
	check("2b 未知模型不会返回 Model 枚举", not isinstance(unknown, Model))

	# gemini-webapi 内置「版本无关匹配」:旧名字会被映射到账号里最接近的当前模型,
	# 这比静默改用默认模型更好,属于库的正确行为,这里锁定住别退化。
	legacy = app_main.resolve_model_for_client(probe, "gemini-1.5-pro")
	check("2c 旧模型名按版本无关规则映射到当前 pro 模型",
	      isinstance(legacy, AvailableModel) and legacy.model_name == "gemini-3-pro", repr(legacy))
	check("2d 空模型名返回 None", app_main.resolve_model_for_client(probe, "") is None)
	check("2e client 为 None 时返回 None", app_main.resolve_model_for_client(None, "gemini-3-pro") is None)

	# 扩展思考标记的剥离:必须在 resolve 之前做,否则那个名字它不认识,会回退到默认模型
	check(
		"2f -thinking 后缀被剥离",
		app_main.split_extended_thinking_marker("gemini-3.8-flash-thinking") == ("gemini-3.8-flash", True),
	)
	check(
		"2g @extended 后缀被剥离",
		app_main.split_extended_thinking_marker("gemini-3.8-flash@extended") == ("gemini-3.8-flash", True),
	)
	check(
		"2h 无标记时原样返回",
		app_main.split_extended_thinking_marker("gemini-3.8-flash") == ("gemini-3.8-flash", False),
	)
	check("2i 大小写不敏感", app_main.split_extended_thinking_marker("X-THINKING") == ("X", True))
	check("2j 空值不报错", app_main.split_extended_thinking_marker("") == ("", False))
	check(
		"2k 剥离后的名字仍能正常解析",
		getattr(app_main.resolve_model_for_client(probe, "gemini-3-pro"), "model_name", None) == "gemini-3-pro",
	)

	with TestClient(app_main.app) as client:
		deadline = time.time() + 20
		while time.time() < deadline and not app_main.session_manager.status()["initialized"]:
			time.sleep(0.1)
		check("3 会话已初始化", app_main.session_manager.status()["initialized"] is True)

		# ---------------------------------------------------------- /v1/models
		res = client.get("/v1/models")
		check("4a /v1/models 返回 200", res.status_code == 200, str(res.status_code))
		body = res.json()
		ids = [m["id"] for m in body["data"]]
		print(f"       账号模型: {ids}  source={body.get('source')}")

		check("4b 来源标记为 account", body.get("source") == "account", str(body.get("source")))
		base_ids = [i for i in ids if not i.endswith("-thinking")]
		thinking_ids = [i for i in ids if i.endswith("-thinking")]
		check("4c 列出真实模型(带版本号)", base_ids == ["gemini-3-pro", "gemini-3-flash"], str(ids))
		check("4h 每个模型都有扩展思考变体", thinking_ids == ["gemini-3-pro-thinking", "gemini-3-flash-thinking"], str(ids))
		check("4i 扩展思考变体标明了 extends", body["data"][1].get("extends") == "gemini-3-pro", str(body["data"][1]))
		check("4d 过滤掉不可选用的模型", not any("flash-lite" in i for i in ids), str(ids))
		check("4e 不再出现废弃枚举的模型", not any("plus" in i or "advanced" in i for i in ids), str(ids))
		check("4f 带上了 display_name", body["data"][0].get("display_name") == "Gemini 3 Pro", str(body["data"][0]))
		check("4g 带上了 description", bool(body["data"][0].get("description")))

		# ---------------------------------------------------------- 客户端未就绪时的兜底
		saved = app_main.session_manager.client
		app_main.session_manager.client = None
		try:
			body_fb = client.get("/v1/models").json()
			check("5a 客户端未就绪时来源为 fallback", body_fb.get("source") == "fallback", str(body_fb.get("source")))
			check("5b 兜底列表非空", len(body_fb["data"]) > 0)
		finally:
			app_main.session_manager.client = saved

		# ---------------------------------------------------------- 请求里的模型被真实解析
		live = app_main.session_manager.client
		live.generate_calls.clear()
		res = client.post("/v1/chat/completions", json={
			"model": "gemini-3-pro",
			"messages": [{"role": "user", "content": "hi"}],
		})
		check("6a 指定模型时请求成功", res.status_code == 200, f"{res.status_code} {res.text[:160]}")
		passed_model = live.generate_calls[-1].get("model")
		check("6b 传给 generate_content 的是 AvailableModel", isinstance(passed_model, AvailableModel), repr(passed_model))
		check("6c 模型正是请求的那个", getattr(passed_model, "model_name", None) == "gemini-3-pro", repr(passed_model))
		check("6d 不是废弃的 Model 枚举", not isinstance(passed_model, Model))

		# ---------------------------------------------------------- 未知模型 -> 交给 Gemini 默认
		live.generate_calls.clear()
		res = client.post("/v1/chat/completions", json={
			"model": "totally-unknown-model-xyz",
			"messages": [{"role": "user", "content": "hi"}],
		})
		check("7a 未知模型时请求仍成功", res.status_code == 200, f"{res.status_code} {res.text[:160]}")
		check("7b 未知模型时不发送 model 参数(让 Gemini 用默认)", "model" not in live.generate_calls[-1],
		      str(live.generate_calls[-1].keys()))

		# ---------------------------------------------------------- 扩展思考(长输出)
		live.generate_calls.clear()
		res = client.post("/v1/chat/completions", json={
			"model": "gemini-3-pro-thinking",
			"messages": [{"role": "user", "content": "hi"}],
		})
		check("7c 带 -thinking 的请求成功", res.status_code == 200, f"{res.status_code} {res.text[:140]}")
		call = live.generate_calls[-1]
		check("7d 传了 extended_thinking=True", call.get("extended_thinking") is True, str(call.keys()))
		check(
			"7e -thinking 后缀已剥离,模型正确解析",
			getattr(call.get("model"), "model_name", None) == "gemini-3-pro",
			repr(call.get("model")),
		)

		live.generate_calls.clear()
		client.post("/v1/chat/completions", json={
			"model": "gemini-3-pro",
			"messages": [{"role": "user", "content": "hi"}],
		})
		check(
			"7f 普通模型不开启扩展思考",
			"extended_thinking" not in live.generate_calls[-1],
			str(live.generate_calls[-1].keys()),
		)

	# ---------------------------------------------------------- 对外 id 要带上版本号
	# 账号真实返回的形态:model_name 没有版本号,版本只在 display_name 里
	versioned = FakeClient()
	versioned._real._model_registry = {
		"aaa111": AvailableModel(
			model_id="aaa111", model_name="gemini-flash", display_name="3.8 Flash",
			description="", capacity=1, capacity_field=12, model_number=1,
			aliases=["gemini-3.8-flash", "flash"],
		),
		"bbb222": AvailableModel(
			model_id="bbb222", model_name="gemini-pro", display_name="3.1 Pro",
			description="", capacity=1, capacity_field=12, model_number=3,
			aliases=["gemini-3.1-pro", "pro"],
		),
	}
	flash = versioned._real._model_registry["aaa111"]
	pro = versioned._real._model_registry["bbb222"]

	id_flash = app_main.public_model_id(versioned, flash)
	id_pro = app_main.public_model_id(versioned, pro)
	print(f"       对外 id: {id_flash} / {id_pro}")

	check("8a 对外 id 带上版本号(flash)", id_flash == "gemini-3.8-flash", id_flash)
	check("8b 对外 id 带上版本号(pro)", id_pro == "gemini-3.1-pro", id_pro)
	check("8c 新 id 能精确解析回同一个模型", versioned.resolve_model(id_flash).model_id == "aaa111")
	check("8d 新 id 不会串到别的模型", versioned.resolve_model(id_pro).model_id == "bbb222")

	# 库的版本无关匹配(MODEL_PREFIX_RE = ^gemini-(?:\d+(?:\.\d+)?-)?)会把
	# gemini-<任意版本>-<档位> 归一化成 <档位>,所以带版本号的 id 天然能解析回来。
	orphan = AvailableModel(
		model_id="ccc333", model_name="gemini-lite", display_name="9.9 Lite",
		description="", capacity=1, capacity_field=12, model_number=6,
	)
	versioned._real._model_registry["ccc333"] = orphan
	check("8e 即使 model_name 无版本号,也能生成带版本号的 id",
	      app_main.public_model_id(versioned, orphan) == "gemini-9.9-lite",
	      app_main.public_model_id(versioned, orphan))
	check("8e2 且能解析回同一模型",
	      versioned.resolve_model("gemini-9.9-lite").model_id == "ccc333")

	# 真正需要兜底的场景:display_name 的 slug 撞上了**另一个**模型,
	# 此时必须退回 model_name,否则请求会被打到别的模型上。
	collide = AvailableModel(
		model_id="fff666", model_name="gemini-flash-lite-x", display_name="Flash",
		description="", capacity=1, capacity_field=12, model_number=6,
	)
	versioned._real._model_registry["fff666"] = collide
	check("8f slug 撞上别的模型时退回 model_name",
	      app_main.public_model_id(versioned, collide) == "gemini-flash-lite-x",
	      app_main.public_model_id(versioned, collide))
	check("8g display_name 为空时退回 model_name",
	      app_main.public_model_id(versioned, AvailableModel(
		      model_id="ddd444", model_name="gemini-x", display_name="",
		      description="", capacity=1, capacity_field=12, model_number=1)) == "gemini-x")
	check("8h 与 model_name 相同时不画蛇添足",
	      app_main.public_model_id(versioned, AvailableModel(
		      model_id="eee555", model_name="gemini-3.5-flash-lite", display_name="3.5 Flash-Lite",
		      description="", capacity=1, capacity_field=12, model_number=6)) == "gemini-3.5-flash-lite")

	print(f"\n通过 {len(PASS)} / {len(PASS) + len(FAIL)}")
	if FAIL:
		print("失败项:")
		for name in FAIL:
			print("  -", name)
	return 1 if FAIL else 0


if __name__ == "__main__":
	sys.exit(main())