@agent-proxy/agy 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/catalog.d.ts +86 -0
- package/dist/catalog.js +442 -0
- package/dist/credentials.d.ts +36 -0
- package/dist/credentials.js +84 -0
- package/dist/fingerprint-data.json +8 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +388 -0
- package/dist/request.d.ts +28 -0
- package/dist/request.js +504 -0
- package/dist/resources/models.js +3854 -0
- package/dist/response.d.ts +22 -0
- package/dist/response.js +445 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.js +84 -0
- package/package.json +40 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codex-cliproxy-gateway contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ModelCatalog, ModelEntry } from "@agent-proxy/plugin-api";
|
|
2
|
+
import type { AgyCredentials } from "./credentials.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Antigravity 模型目录:`POST /v1internal:fetchAvailableModels` 拉取,以 `models`
|
|
5
|
+
* map 合成 `agy/` 前缀的 Codex 目录条目;`deprecatedModelIds` 提供旧 id 到新 id 的
|
|
6
|
+
* 服务端重定向。多档位家族(同一基名的 high/medium/low 变体)在目录中合并为一个
|
|
7
|
+
* `agy/<base>` 条目,转发时按 reasoning.effort 解析具体上游模型。缓存键带账号身份
|
|
8
|
+
* 与端点摘要;缓存文件不含任何凭据。
|
|
9
|
+
*/
|
|
10
|
+
export declare const AGY_PREFIX = "agy/";
|
|
11
|
+
/** 显示名前缀:与路由 slug 区分的大写标签(对齐 CodeBuddy 的 CB-/WB- 风格)。 */
|
|
12
|
+
export declare const AGY_DISPLAY_PREFIX = "AGY/";
|
|
13
|
+
export declare function isAgyModel(model: unknown): boolean;
|
|
14
|
+
/** `agy/<model-id>` → 裸模型 ID;不含前缀或前缀后为空返回 undefined。 */
|
|
15
|
+
export declare function agyUpstreamModel(model: string): string | undefined;
|
|
16
|
+
export interface AgyModelDetails {
|
|
17
|
+
id: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
supportsImages: boolean;
|
|
20
|
+
supportsThinking: boolean;
|
|
21
|
+
supportsVideo: boolean;
|
|
22
|
+
maxTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
export type AgyTier = "high" | "medium" | "low";
|
|
25
|
+
/**
|
|
26
|
+
* 多档位家族:同一基名(去 `-high/-medium/-low` 后缀)的变体集合。目录只暴露
|
|
27
|
+
* `agy/<base>` 一个条目,具体上游模型由请求的 reasoning.effort 解析;显式档位
|
|
28
|
+
* id(如 `agy/gemini-3.8-flash-high`)仍按成员 id 直连。
|
|
29
|
+
*/
|
|
30
|
+
export interface AgyModelFamily {
|
|
31
|
+
base: string;
|
|
32
|
+
tiers: Partial<Record<AgyTier, string>>;
|
|
33
|
+
displayName: string;
|
|
34
|
+
}
|
|
35
|
+
export interface AgyCatalogData {
|
|
36
|
+
models: AgyModelDetails[];
|
|
37
|
+
families: AgyModelFamily[];
|
|
38
|
+
reroute: Map<string, string>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 解析 fetchAvailableModels 响应。只消费 models map 与排除清单;图像生成与音频
|
|
42
|
+
* 转写类模型无法走 Responses 文本链路,从目录中剔除。未知字段一律忽略。
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseAgyCatalogData(value: unknown): AgyCatalogData;
|
|
45
|
+
/** effort(缺省或未知值)→ 目标档位;与 thinkingLevel 的档位语义一致,缺省 medium。 */
|
|
46
|
+
export declare function agyEffortTier(effort: unknown): AgyTier;
|
|
47
|
+
/** 家族内按档位解析上游模型 id;目标档缺失时按「就近偏高」回退,家族必有成员。 */
|
|
48
|
+
export declare function resolveAgyFamilyModel(family: AgyModelFamily, effort: unknown): string;
|
|
49
|
+
/** Codex 行为字段沿用 gpt-5.5 快照基底;模型名与能力仅由当前账号目录决定。
|
|
50
|
+
* 多档位家族合并为一个 `agy/<base>` 条目(能力/窗口取成员并集或最大值),
|
|
51
|
+
* `supported_reasoning_levels` 只列家族实际存在的档位;无档位后缀的模型保持
|
|
52
|
+
* 独立条目且不暴露档位选择。 */
|
|
53
|
+
export declare function buildAgyCatalog(data: AgyCatalogData): ModelCatalog;
|
|
54
|
+
export declare function mergeAgyCatalog(base: ModelCatalog, agy: ModelCatalog): ModelCatalog;
|
|
55
|
+
export interface AgyCatalogStoreOptions {
|
|
56
|
+
cacheDirectory: string;
|
|
57
|
+
credentials: () => Promise<AgyCredentials | null>;
|
|
58
|
+
/** 返回 fetchAvailableModels 的已解码 JSON;认证与超时由传输层负责。 */
|
|
59
|
+
fetchCatalog: (credential: AgyCredentials) => Promise<unknown>;
|
|
60
|
+
codexModelsCacheFile?: string;
|
|
61
|
+
endpoint?: string;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
ttlMs?: number;
|
|
64
|
+
/** manual 仅允许显式刷新;目录查询不按 TTL 拉取。 */
|
|
65
|
+
catalogMode?: "dynamic" | "manual";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 目录存储:内存 + 磁盘双指纹缓存、TTL、单飞刷新、失败回退 last-good。
|
|
69
|
+
* 账号切换(identity 变化)后旧 key 条目不复用。
|
|
70
|
+
*/
|
|
71
|
+
export declare function createAgyCatalogStore(options: AgyCatalogStoreOptions): {
|
|
72
|
+
/** 返回目录条目、档位家族与旧 id 重定向表;无凭据或完全无缓存时抛错。 */
|
|
73
|
+
catalog(): Promise<{
|
|
74
|
+
models: ModelEntry[];
|
|
75
|
+
families: AgyModelFamily[];
|
|
76
|
+
reroute: Map<string, string>;
|
|
77
|
+
}>;
|
|
78
|
+
/** 重读当前账号目录及路由元数据,不请求网络。 */
|
|
79
|
+
reload(): Promise<{
|
|
80
|
+
models: ModelEntry[];
|
|
81
|
+
families: AgyModelFamily[];
|
|
82
|
+
reroute: Map<string, string>;
|
|
83
|
+
}>;
|
|
84
|
+
/** 启动/定时刷新入口:绕过 TTL 重新校验。 */
|
|
85
|
+
refresh(): Promise<void>;
|
|
86
|
+
};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { getCodexModelSnapshot } from "./resources/models.js";
|
|
5
|
+
import { invalidateModelsCache, withAgentSystemPrompt } from "@agent-proxy/plugin-api";
|
|
6
|
+
import { atomicWrite } from "@agent-proxy/plugin-api";
|
|
7
|
+
import { AGY_AGENT_SYSTEM_PROMPT, AGY_ENDPOINT } from "./transport.js";
|
|
8
|
+
import { THINKING_LEVELS } from "./request.js";
|
|
9
|
+
/**
|
|
10
|
+
* Antigravity 模型目录:`POST /v1internal:fetchAvailableModels` 拉取,以 `models`
|
|
11
|
+
* map 合成 `agy/` 前缀的 Codex 目录条目;`deprecatedModelIds` 提供旧 id 到新 id 的
|
|
12
|
+
* 服务端重定向。多档位家族(同一基名的 high/medium/low 变体)在目录中合并为一个
|
|
13
|
+
* `agy/<base>` 条目,转发时按 reasoning.effort 解析具体上游模型。缓存键带账号身份
|
|
14
|
+
* 与端点摘要;缓存文件不含任何凭据。
|
|
15
|
+
*/
|
|
16
|
+
export const AGY_PREFIX = "agy/";
|
|
17
|
+
/** 显示名前缀:与路由 slug 区分的大写标签(对齐 CodeBuddy 的 CB-/WB- 风格)。 */
|
|
18
|
+
export const AGY_DISPLAY_PREFIX = "AGY/";
|
|
19
|
+
/** 目录缓存修订号:合成规则变化时递增,避免复用旧结构的磁盘缓存
|
|
20
|
+
* (v2:档位家族合并;v3:levels 按实际档位重建;v4:删 web_search_tool_type;
|
|
21
|
+
* v5:v3/v4 的字段删除改为保留字段置中性值——缺字段疑似导致 Codex 弃用目录;
|
|
22
|
+
* v6:系统提示词在合成时替换(base_instructions + model_messages 模板))。 */
|
|
23
|
+
const CACHE_REVISION = 6;
|
|
24
|
+
const CATALOG_TTL_MS = 6 * 60 * 1000;
|
|
25
|
+
const FAILURE_COOLDOWN_MS = 30_000;
|
|
26
|
+
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
27
|
+
export function isAgyModel(model) {
|
|
28
|
+
return typeof model === "string" && model.toLowerCase().startsWith(AGY_PREFIX);
|
|
29
|
+
}
|
|
30
|
+
/** `agy/<model-id>` → 裸模型 ID;不含前缀或前缀后为空返回 undefined。 */
|
|
31
|
+
export function agyUpstreamModel(model) {
|
|
32
|
+
if (!isAgyModel(model))
|
|
33
|
+
return undefined;
|
|
34
|
+
const bare = model.slice(AGY_PREFIX.length);
|
|
35
|
+
return bare && !bare.includes("/") ? bare : undefined;
|
|
36
|
+
}
|
|
37
|
+
function record(value) {
|
|
38
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
39
|
+
? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
function positiveNumber(value) {
|
|
42
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 解析 fetchAvailableModels 响应。只消费 models map 与排除清单;图像生成与音频
|
|
46
|
+
* 转写类模型无法走 Responses 文本链路,从目录中剔除。未知字段一律忽略。
|
|
47
|
+
*/
|
|
48
|
+
export function parseAgyCatalogData(value) {
|
|
49
|
+
const data = record(value);
|
|
50
|
+
const modelsRaw = record(data?.models);
|
|
51
|
+
if (!data || !modelsRaw)
|
|
52
|
+
throw new Error("Antigravity 模型目录格式错误:缺少 models 映射");
|
|
53
|
+
const strings = (source) => (Array.isArray(source) ? source : []).filter((id) => typeof id === "string" && id.length > 0);
|
|
54
|
+
// tab 补全与图像/音频专用模型不能走 Responses 文本链路,一律剔除。
|
|
55
|
+
const excluded = new Set([
|
|
56
|
+
...strings(data.tabModelIds),
|
|
57
|
+
...strings(data.imageGenerationModelIds),
|
|
58
|
+
...strings(data.audioTranscriptionModelIds),
|
|
59
|
+
]);
|
|
60
|
+
// agentModelSorts 是官方选择器的推荐位;tieredModelIds 给出各档位当前指向的具体模型。
|
|
61
|
+
// 两者都存在时目录取其并集(推荐位在前),否则回退 models 全集。
|
|
62
|
+
const agentIds = (Array.isArray(data.agentModelSorts) ? data.agentModelSorts : [])
|
|
63
|
+
.flatMap((sort) => record(sort)?.groups)
|
|
64
|
+
.flatMap((group) => strings(record(group)?.modelIds));
|
|
65
|
+
const tiered = record(data.tieredModelIds) ?? {};
|
|
66
|
+
const tieredIds = Object.values(tiered).filter((id) => typeof id === "string" && id.length > 0);
|
|
67
|
+
const selection = agentIds.length ? [...new Set([...agentIds, ...tieredIds])] : undefined;
|
|
68
|
+
let models = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const [id, raw] of Object.entries(modelsRaw)) {
|
|
71
|
+
if (!id.trim() || id !== id.trim() || /[\s/\\]/.test(id)) {
|
|
72
|
+
throw new Error("Antigravity 模型目录格式错误:模型 id 无效");
|
|
73
|
+
}
|
|
74
|
+
if (seen.has(id))
|
|
75
|
+
throw new Error("Antigravity 模型目录格式错误:模型 id 重复");
|
|
76
|
+
seen.add(id);
|
|
77
|
+
if (excluded.has(id))
|
|
78
|
+
continue;
|
|
79
|
+
if (selection !== undefined && !selection.includes(id))
|
|
80
|
+
continue;
|
|
81
|
+
const details = record(raw) ?? {};
|
|
82
|
+
const displayName = typeof details.displayName === "string" && details.displayName.trim()
|
|
83
|
+
? details.displayName.trim() : id;
|
|
84
|
+
const model = {
|
|
85
|
+
id,
|
|
86
|
+
displayName,
|
|
87
|
+
supportsImages: details.supportsImages === true,
|
|
88
|
+
supportsThinking: details.supportsThinking === true,
|
|
89
|
+
supportsVideo: details.supportsVideo === true,
|
|
90
|
+
};
|
|
91
|
+
if (positiveNumber(details.maxTokens))
|
|
92
|
+
model.maxTokens = details.maxTokens;
|
|
93
|
+
models.push(model);
|
|
94
|
+
}
|
|
95
|
+
if (models.length === 0)
|
|
96
|
+
throw new Error("Antigravity 模型目录格式错误:可用模型为空");
|
|
97
|
+
// 推荐位顺序即展示优先级:有 selection 时按其顺序输出,selection 外的条目已被过滤。
|
|
98
|
+
if (selection !== undefined) {
|
|
99
|
+
const byId = new Map(models.map((model) => [model.id, model]));
|
|
100
|
+
models = selection.flatMap((id) => {
|
|
101
|
+
const model = byId.get(id);
|
|
102
|
+
return model && !excluded.has(id) ? [model] : [];
|
|
103
|
+
});
|
|
104
|
+
if (models.length === 0)
|
|
105
|
+
throw new Error("Antigravity 模型目录格式错误:可用模型为空");
|
|
106
|
+
}
|
|
107
|
+
const reroute = new Map();
|
|
108
|
+
const deprecated = record(data.deprecatedModelIds);
|
|
109
|
+
if (deprecated) {
|
|
110
|
+
for (const [oldId, info] of Object.entries(deprecated)) {
|
|
111
|
+
const target = record(info);
|
|
112
|
+
if (typeof target?.newModelId === "string" && target.newModelId && target.newModelId !== oldId) {
|
|
113
|
+
reroute.set(oldId, target.newModelId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// 多档位家族识别:按 `-high/-medium/-low` 后缀分组;仅两个成员以上的组才合并
|
|
118
|
+
// (单档位后缀模型与无后缀模型保持独立),且基名与显式无后缀模型冲突时不合并
|
|
119
|
+
// (避免目录出现重复 slug)。已改名的档位(如 gemini-pro-agent)不强行归族。
|
|
120
|
+
const listed = new Set(models.map((model) => model.id));
|
|
121
|
+
const grouped = new Map();
|
|
122
|
+
for (const model of models) {
|
|
123
|
+
const match = /^(.+)-(high|medium|low)$/.exec(model.id);
|
|
124
|
+
if (!match)
|
|
125
|
+
continue;
|
|
126
|
+
const [, base, tier] = match;
|
|
127
|
+
grouped.set(base, { ...(grouped.get(base) ?? {}), [tier]: model.id });
|
|
128
|
+
}
|
|
129
|
+
const families = [];
|
|
130
|
+
for (const [base, tiers] of grouped) {
|
|
131
|
+
if (Object.keys(tiers).length < 2 || listed.has(base))
|
|
132
|
+
continue;
|
|
133
|
+
const memberIds = Object.values(tiers).filter((id) => typeof id === "string");
|
|
134
|
+
// 展示名取 medium 成员的显示名(无则首个成员),去掉档位括号后缀。
|
|
135
|
+
const preferred = models.find((model) => model.id === tiers.medium)
|
|
136
|
+
?? models.find((model) => memberIds.includes(model.id));
|
|
137
|
+
families.push({
|
|
138
|
+
base, tiers,
|
|
139
|
+
displayName: preferred ? preferred.displayName.replace(/\s*\((High|Medium|Low)\)$/, "") : base,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return { models, families, reroute };
|
|
143
|
+
}
|
|
144
|
+
/** effort(缺省或未知值)→ 目标档位;与 thinkingLevel 的档位语义一致,缺省 medium。 */
|
|
145
|
+
export function agyEffortTier(effort) {
|
|
146
|
+
if (typeof effort !== "string")
|
|
147
|
+
return "medium";
|
|
148
|
+
return THINKING_LEVELS[effort] ?? "medium";
|
|
149
|
+
}
|
|
150
|
+
const TIER_FALLBACK = {
|
|
151
|
+
low: ["low", "medium", "high"],
|
|
152
|
+
medium: ["medium", "high", "low"],
|
|
153
|
+
high: ["high", "medium", "low"],
|
|
154
|
+
};
|
|
155
|
+
/** 家族内按档位解析上游模型 id;目标档缺失时按「就近偏高」回退,家族必有成员。 */
|
|
156
|
+
export function resolveAgyFamilyModel(family, effort) {
|
|
157
|
+
for (const tier of TIER_FALLBACK[agyEffortTier(effort)]) {
|
|
158
|
+
const id = family.tiers[tier];
|
|
159
|
+
if (typeof id === "string" && id)
|
|
160
|
+
return id;
|
|
161
|
+
}
|
|
162
|
+
return family.base;
|
|
163
|
+
}
|
|
164
|
+
/** 基底快照里 low/medium/high 档位的展示文案(复用到家族条目;xhigh/minimal 不沿用)。 */
|
|
165
|
+
const baseLevelDescriptions = (base) => new Map((Array.isArray(base.supported_reasoning_levels)
|
|
166
|
+
? base.supported_reasoning_levels
|
|
167
|
+
: []).filter((level) => typeof level.effort === "string" && typeof level.description === "string")
|
|
168
|
+
.map((level) => [level.effort, level.description]));
|
|
169
|
+
/** Codex 行为字段沿用 gpt-5.5 快照基底;模型名与能力仅由当前账号目录决定。
|
|
170
|
+
* 多档位家族合并为一个 `agy/<base>` 条目(能力/窗口取成员并集或最大值),
|
|
171
|
+
* `supported_reasoning_levels` 只列家族实际存在的档位;无档位后缀的模型保持
|
|
172
|
+
* 独立条目且不暴露档位选择。 */
|
|
173
|
+
export function buildAgyCatalog(data) {
|
|
174
|
+
const BASE = getCodexModelSnapshot().models.find((entry) => entry.slug === "gpt-5.5");
|
|
175
|
+
if (!BASE)
|
|
176
|
+
throw new Error("Codex 模型快照缺少 gpt-5.5 基底条目");
|
|
177
|
+
const BASE_LEVEL_DESCRIPTIONS = baseLevelDescriptions(BASE);
|
|
178
|
+
const familyByMember = new Map();
|
|
179
|
+
for (const family of data.families) {
|
|
180
|
+
for (const id of Object.values(family.tiers)) {
|
|
181
|
+
if (typeof id === "string" && id)
|
|
182
|
+
familyByMember.set(id, family);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const toEntry = (details, merged, priority, tiers = []) => {
|
|
186
|
+
const model = structuredClone(BASE);
|
|
187
|
+
model.slug = `${AGY_PREFIX}${details.id}`;
|
|
188
|
+
model.display_name = `${AGY_DISPLAY_PREFIX}${details.displayName}`;
|
|
189
|
+
model.description = merged
|
|
190
|
+
? `通过本机 Antigravity CLI 授权调用 ${details.displayName},reasoning effort 决定档位变体`
|
|
191
|
+
: `通过本机 Antigravity CLI 授权调用 ${details.displayName}`;
|
|
192
|
+
model.priority = priority;
|
|
193
|
+
model.prefer_websockets = false;
|
|
194
|
+
delete model.minimal_client_version;
|
|
195
|
+
model.input_modalities = details.supportsImages ? ["text", "image"] : ["text"];
|
|
196
|
+
model.supports_reasoning_summaries = details.supportsThinking;
|
|
197
|
+
model.supports_reasoning_summary_parameter = false;
|
|
198
|
+
model.supports_image_detail_original = false;
|
|
199
|
+
model.supports_search_tool = false;
|
|
200
|
+
// 上游没有搜索通道:能力由 supports_search_tool=false 关闭。字段本身保留基底的值,
|
|
201
|
+
// 不做删除——Codex 的目录反序列化可能要求字段存在,缺字段会导致整个目录被弃用。
|
|
202
|
+
model.support_verbosity = false;
|
|
203
|
+
model.additional_speed_tiers = [];
|
|
204
|
+
model.service_tiers = [];
|
|
205
|
+
delete model.default_service_tier;
|
|
206
|
+
delete model.default_reasoning_level;
|
|
207
|
+
delete model.default_reasoning_summary;
|
|
208
|
+
// 快照基底的 supported_reasoning_levels 是 OpenAI 风格档位(含 xhigh/minimal),
|
|
209
|
+
// 上游只有 high/medium/low 变体:家族条目必须只列出实际存在的档位,否则客户端
|
|
210
|
+
// 会发出不存在的档位。默认档位与转发侧缺省解析一致(medium → high → low)。
|
|
211
|
+
// 字段一律保留(无档位的条目置空数组),不删除——缺字段可能让 Codex 弃用整个目录。
|
|
212
|
+
if (merged && tiers.length) {
|
|
213
|
+
model.supported_reasoning_levels = tiers.map((tier) => ({
|
|
214
|
+
effort: tier,
|
|
215
|
+
description: BASE_LEVEL_DESCRIPTIONS.get(tier) ?? "",
|
|
216
|
+
}));
|
|
217
|
+
model.default_reasoning_level = tiers.includes("medium") ? "medium"
|
|
218
|
+
: tiers.includes("high") ? "high" : "low";
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
model.supported_reasoning_levels = [];
|
|
222
|
+
model.default_reasoning_level = "medium";
|
|
223
|
+
}
|
|
224
|
+
// Gemini 系后端支持并行函数调用;目录未声明时按支持处理。
|
|
225
|
+
model.supports_parallel_tool_calls = true;
|
|
226
|
+
if (details.maxTokens !== undefined) {
|
|
227
|
+
model.context_window = details.maxTokens;
|
|
228
|
+
model.max_context_window = details.maxTokens;
|
|
229
|
+
model.effective_context_window_percent = 95;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
delete model.context_window;
|
|
233
|
+
delete model.max_context_window;
|
|
234
|
+
delete model.effective_context_window_percent;
|
|
235
|
+
}
|
|
236
|
+
return withAgentSystemPrompt(model, AGY_AGENT_SYSTEM_PROMPT);
|
|
237
|
+
};
|
|
238
|
+
const emitted = new Set();
|
|
239
|
+
const models = [];
|
|
240
|
+
for (const member of data.models) {
|
|
241
|
+
const family = familyByMember.get(member.id);
|
|
242
|
+
if (family) {
|
|
243
|
+
if (emitted.has(family))
|
|
244
|
+
continue;
|
|
245
|
+
emitted.add(family);
|
|
246
|
+
const members = data.models.filter((item) => familyByMember.get(item.id) === family);
|
|
247
|
+
const maxTokens = Math.max(0, ...members.map((item) => item.maxTokens ?? 0));
|
|
248
|
+
models.push(toEntry({
|
|
249
|
+
id: family.base,
|
|
250
|
+
displayName: family.displayName,
|
|
251
|
+
supportsImages: members.some((item) => item.supportsImages),
|
|
252
|
+
supportsThinking: members.some((item) => item.supportsThinking),
|
|
253
|
+
...(maxTokens > 0 ? { maxTokens } : {}),
|
|
254
|
+
}, true, models.length, orderedTiers(family)));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
models.push(toEntry({
|
|
258
|
+
id: member.id,
|
|
259
|
+
displayName: member.displayName,
|
|
260
|
+
supportsImages: member.supportsImages,
|
|
261
|
+
supportsThinking: member.supportsThinking,
|
|
262
|
+
...(member.maxTokens !== undefined ? { maxTokens: member.maxTokens } : {}),
|
|
263
|
+
}, false, models.length));
|
|
264
|
+
}
|
|
265
|
+
return { models };
|
|
266
|
+
}
|
|
267
|
+
/** 家族档位按 low → high 排序输出;保证目录展示顺序稳定。 */
|
|
268
|
+
function orderedTiers(family) {
|
|
269
|
+
return ["low", "medium", "high"].filter((tier) => family.tiers[tier] !== undefined);
|
|
270
|
+
}
|
|
271
|
+
export function mergeAgyCatalog(base, agy) {
|
|
272
|
+
const models = base.models.filter((entry) => !isAgyModel(entry.slug));
|
|
273
|
+
const priority = Math.max(0, ...models.map((entry) => Number(entry.priority) || 0)) + 100;
|
|
274
|
+
return { models: [...models, ...agy.models.map((entry, index) => ({ ...entry, priority: priority + index }))] };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* 目录存储:内存 + 磁盘双指纹缓存、TTL、单飞刷新、失败回退 last-good。
|
|
278
|
+
* 账号切换(identity 变化)后旧 key 条目不复用。
|
|
279
|
+
*/
|
|
280
|
+
export function createAgyCatalogStore(options) {
|
|
281
|
+
const now = options.now ?? Date.now;
|
|
282
|
+
const ttlMs = Math.max(60_000, options.ttlMs ?? CATALOG_TTL_MS);
|
|
283
|
+
const endpoint = options.endpoint ?? AGY_ENDPOINT;
|
|
284
|
+
const endpointDigest = createHash("sha256").update(endpoint).digest("hex").slice(0, 16);
|
|
285
|
+
const cacheFile = path.join(options.cacheDirectory, "agy-catalog.json");
|
|
286
|
+
let cached;
|
|
287
|
+
let retryAt = 0;
|
|
288
|
+
let refreshing;
|
|
289
|
+
let refreshingForce = false;
|
|
290
|
+
let pendingForce = false;
|
|
291
|
+
const cacheKey = (credential) => digest({ revision: CACHE_REVISION, identity: credential.identity, endpoint: endpointDigest });
|
|
292
|
+
/** 家族结构与模型列表共同构成目录内容:任一变化都需失效 Codex 的目录缓存。 */
|
|
293
|
+
const contentDigest = (value) => digest({ models: value.models, families: value.families });
|
|
294
|
+
/** families 磁盘形状校验:条目不合法视为整体缓存不可用。 */
|
|
295
|
+
function readFamilies(value) {
|
|
296
|
+
if (!Array.isArray(value))
|
|
297
|
+
return undefined;
|
|
298
|
+
const families = [];
|
|
299
|
+
for (const raw of value) {
|
|
300
|
+
const family = record(raw);
|
|
301
|
+
const tiers = family ? record(family.tiers) : undefined;
|
|
302
|
+
if (!family || typeof family.base !== "string" || !family.base || /[\s/\\]/.test(family.base)
|
|
303
|
+
|| typeof family.displayName !== "string" || !family.displayName
|
|
304
|
+
|| !tiers || Object.keys(tiers).length === 0
|
|
305
|
+
|| !Object.entries(tiers).every(([tier, id]) => ["high", "medium", "low"].includes(tier)
|
|
306
|
+
&& typeof id === "string" && id.length > 0)) {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
families.push({ base: family.base, displayName: family.displayName, tiers });
|
|
310
|
+
}
|
|
311
|
+
return families;
|
|
312
|
+
}
|
|
313
|
+
function readDisk(key) {
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
316
|
+
if (parsed.cache_key !== key || typeof parsed.fetched_at !== "number" || !Array.isArray(parsed.models))
|
|
317
|
+
return;
|
|
318
|
+
const models = parsed.models;
|
|
319
|
+
if (parsed.content_hash !== digest({ models, families: parsed.families }))
|
|
320
|
+
return;
|
|
321
|
+
const families = readFamilies(parsed.families);
|
|
322
|
+
if (!families)
|
|
323
|
+
return;
|
|
324
|
+
const reroute = Array.isArray(parsed.reroute)
|
|
325
|
+
&& parsed.reroute.every((pair) => Array.isArray(pair) && pair.length === 2
|
|
326
|
+
&& typeof pair[0] === "string" && typeof pair[1] === "string")
|
|
327
|
+
? parsed.reroute : [];
|
|
328
|
+
return { key, fetchedAt: parsed.fetched_at, models, families, reroute };
|
|
329
|
+
}
|
|
330
|
+
catch { /* 缺失或损坏的缓存按未命中处理。 */ }
|
|
331
|
+
}
|
|
332
|
+
function writeDisk(value, previous) {
|
|
333
|
+
try {
|
|
334
|
+
atomicWrite(cacheFile, `${JSON.stringify({
|
|
335
|
+
cache_key: value.key,
|
|
336
|
+
fetched_at: value.fetchedAt,
|
|
337
|
+
content_hash: contentDigest(value),
|
|
338
|
+
models: value.models,
|
|
339
|
+
families: value.families,
|
|
340
|
+
reroute: value.reroute,
|
|
341
|
+
}, null, 2)}\n`);
|
|
342
|
+
}
|
|
343
|
+
catch { /* 缓存写失败不影响本次目录返回。 */ }
|
|
344
|
+
if (previous === undefined || contentDigest(previous) !== contentDigest(value)) {
|
|
345
|
+
// 目录内容变化会改变 Codex 能看到的模型列表:过期它自己的目录缓存。
|
|
346
|
+
try {
|
|
347
|
+
if (options.codexModelsCacheFile)
|
|
348
|
+
invalidateModelsCache(options.codexModelsCacheFile);
|
|
349
|
+
}
|
|
350
|
+
catch { /* 客户端缓存不可写不影响网关目录服务。 */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function refreshOnce(force) {
|
|
354
|
+
const credential = await options.credentials();
|
|
355
|
+
if (!credential)
|
|
356
|
+
return;
|
|
357
|
+
const key = cacheKey(credential);
|
|
358
|
+
if (!cached) {
|
|
359
|
+
const disk = readDisk(key);
|
|
360
|
+
if (disk)
|
|
361
|
+
cached = disk;
|
|
362
|
+
}
|
|
363
|
+
if (cached && cached.key !== key)
|
|
364
|
+
cached = undefined;
|
|
365
|
+
const cooling = !force && retryAt > now();
|
|
366
|
+
if (!cooling && (force || !cached || now() - cached.fetchedAt >= ttlMs)) {
|
|
367
|
+
try {
|
|
368
|
+
const data = parseAgyCatalogData(await options.fetchCatalog(credential));
|
|
369
|
+
const previous = cached;
|
|
370
|
+
cached = {
|
|
371
|
+
key,
|
|
372
|
+
fetchedAt: now(),
|
|
373
|
+
models: buildAgyCatalog(data).models,
|
|
374
|
+
families: data.families,
|
|
375
|
+
reroute: [...data.reroute],
|
|
376
|
+
};
|
|
377
|
+
retryAt = 0;
|
|
378
|
+
writeDisk(cached, previous);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// 拉取失败回退 last-good(允许陈旧);完全没有缓存则维持空目录。
|
|
382
|
+
retryAt = now() + FAILURE_COOLDOWN_MS;
|
|
383
|
+
cached ??= readDisk(key);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function refresh(force) {
|
|
388
|
+
if (refreshing) {
|
|
389
|
+
if (force && !refreshingForce)
|
|
390
|
+
pendingForce = true;
|
|
391
|
+
return refreshing;
|
|
392
|
+
}
|
|
393
|
+
refreshingForce = force;
|
|
394
|
+
refreshing = (async () => {
|
|
395
|
+
let nextForce = force;
|
|
396
|
+
do {
|
|
397
|
+
pendingForce = false;
|
|
398
|
+
refreshingForce = nextForce;
|
|
399
|
+
await refreshOnce(nextForce);
|
|
400
|
+
nextForce = pendingForce;
|
|
401
|
+
} while (nextForce);
|
|
402
|
+
})().finally(() => {
|
|
403
|
+
refreshing = undefined;
|
|
404
|
+
refreshingForce = false;
|
|
405
|
+
pendingForce = false;
|
|
406
|
+
});
|
|
407
|
+
return refreshing;
|
|
408
|
+
}
|
|
409
|
+
async function currentCatalog(reload = false) {
|
|
410
|
+
const credential = await options.credentials();
|
|
411
|
+
const key = credential ? cacheKey(credential) : undefined;
|
|
412
|
+
if (!key)
|
|
413
|
+
cached = undefined;
|
|
414
|
+
else if (reload || cached?.key !== key) {
|
|
415
|
+
const disk = readDisk(key);
|
|
416
|
+
if (disk)
|
|
417
|
+
cached = disk;
|
|
418
|
+
else if (cached?.key !== key)
|
|
419
|
+
cached = undefined;
|
|
420
|
+
}
|
|
421
|
+
if (!cached)
|
|
422
|
+
throw new Error("Antigravity 模型目录拉取失败,且没有可用的本地缓存");
|
|
423
|
+
return { models: cached.models, families: cached.families, reroute: new Map(cached.reroute) };
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
/** 返回目录条目、档位家族与旧 id 重定向表;无凭据或完全无缓存时抛错。 */
|
|
427
|
+
async catalog() {
|
|
428
|
+
if (options.catalogMode !== "manual")
|
|
429
|
+
await refresh(false);
|
|
430
|
+
return currentCatalog();
|
|
431
|
+
},
|
|
432
|
+
/** 重读当前账号目录及路由元数据,不请求网络。 */
|
|
433
|
+
async reload() {
|
|
434
|
+
return currentCatalog(true);
|
|
435
|
+
},
|
|
436
|
+
/** 启动/定时刷新入口:绕过 TTL 重新校验。 */
|
|
437
|
+
async refresh() {
|
|
438
|
+
await refresh(true);
|
|
439
|
+
await currentCatalog();
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity CLI(agy)凭据的只读消费。
|
|
3
|
+
*
|
|
4
|
+
* 凭据文件是 CLI 写入的 `~/.gemini/antigravity-cli/antigravity-oauth-token`
|
|
5
|
+
* (keyring 保存失败时的文件回退路径,本机即此形态)。网关只读 access_token 与
|
|
6
|
+
* 过期时间,绝不刷新、绝不写回、绝不把令牌值带进日志或错误信息:
|
|
7
|
+
* access_token 有效期整 1 小时,由 agy 进程(含 remote-control daemon)惰性刷新。
|
|
8
|
+
*/
|
|
9
|
+
export declare class AgyCredentialError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export interface AgyCredentials {
|
|
13
|
+
accessToken: string;
|
|
14
|
+
/** epoch 毫秒;0 表示文件未给出可解析的过期时间,按未过期处理并交由上游 401 兜底。 */
|
|
15
|
+
expiryMs: number;
|
|
16
|
+
/** 账号身份摘要(refresh_token 优先,缺失时用 id_token),只用于目录缓存键。 */
|
|
17
|
+
identity: string;
|
|
18
|
+
/** consumer(个人 Google 账号)或企业登录标记。 */
|
|
19
|
+
authMethod: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AgyCredentialOptions {
|
|
22
|
+
/** 凭据文件路径;缺省取平台默认的 ~/.gemini/antigravity-cli/antigravity-oauth-token。 */
|
|
23
|
+
credentialFile?: string;
|
|
24
|
+
home?: string;
|
|
25
|
+
readFile?: (file: string) => string;
|
|
26
|
+
}
|
|
27
|
+
export declare function defaultAgyCredentialFile(home?: string, os?: NodeJS.Platform): string;
|
|
28
|
+
/** 只探测凭据文件是否存在(Web UI 开关显隐用);不读取也不解析内容。 */
|
|
29
|
+
export declare function agyCredentialsPresent(credentialFile?: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* 读取并校验 agy 凭据。文件缺失与损坏分开报错,损坏时带文件路径与重建指引,
|
|
32
|
+
* 不静默当作未登录。
|
|
33
|
+
*/
|
|
34
|
+
export declare function loadAgyCredentials(options?: AgyCredentialOptions): AgyCredentials;
|
|
35
|
+
/** access_token 是否已过期待判定:过期即快速失败,网关不做任何刷新。 */
|
|
36
|
+
export declare function agyTokenStale(credentials: AgyCredentials, now?: number): boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* Antigravity CLI(agy)凭据的只读消费。
|
|
6
|
+
*
|
|
7
|
+
* 凭据文件是 CLI 写入的 `~/.gemini/antigravity-cli/antigravity-oauth-token`
|
|
8
|
+
* (keyring 保存失败时的文件回退路径,本机即此形态)。网关只读 access_token 与
|
|
9
|
+
* 过期时间,绝不刷新、绝不写回、绝不把令牌值带进日志或错误信息:
|
|
10
|
+
* access_token 有效期整 1 小时,由 agy 进程(含 remote-control daemon)惰性刷新。
|
|
11
|
+
*/
|
|
12
|
+
export class AgyCredentialError extends Error {
|
|
13
|
+
constructor(message) { super(message); this.name = "AgyCredentialError"; }
|
|
14
|
+
}
|
|
15
|
+
/** access_token 判定过期时预留的时钟偏移余量。 */
|
|
16
|
+
const EXPIRY_SKEW_MS = 10_000;
|
|
17
|
+
export function defaultAgyCredentialFile(home = process.env.HOME ?? "", os = process.platform) {
|
|
18
|
+
if (os === "win32") {
|
|
19
|
+
return path.join(home, "AppData", "Local", "Google", "antigravity-cli", "antigravity-oauth-token");
|
|
20
|
+
}
|
|
21
|
+
return path.join(home, ".gemini", "antigravity-cli", "antigravity-oauth-token");
|
|
22
|
+
}
|
|
23
|
+
function parseExpiry(value) {
|
|
24
|
+
// CLI 写入 RFC3339 带 6 位小数秒与时区偏移;无法解析时按未知处理(expiryMs=0),
|
|
25
|
+
// 不因字段怪异拒用整份凭据——上游 401 会兜底。
|
|
26
|
+
if (typeof value !== "string" || !value)
|
|
27
|
+
return 0;
|
|
28
|
+
const parsed = Date.parse(value);
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
30
|
+
}
|
|
31
|
+
/** 只探测凭据文件是否存在(Web UI 开关显隐用);不读取也不解析内容。 */
|
|
32
|
+
export function agyCredentialsPresent(credentialFile = defaultAgyCredentialFile()) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.statSync(credentialFile).isFile();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 读取并校验 agy 凭据。文件缺失与损坏分开报错,损坏时带文件路径与重建指引,
|
|
42
|
+
* 不静默当作未登录。
|
|
43
|
+
*/
|
|
44
|
+
export function loadAgyCredentials(options = {}) {
|
|
45
|
+
const file = options.credentialFile
|
|
46
|
+
?? defaultAgyCredentialFile(options.home ?? process.env.HOME ?? "");
|
|
47
|
+
const read = options.readFile ?? ((source) => fs.readFileSync(source, "utf8"));
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = read(file);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new AgyCredentialError(`未检测到 Antigravity CLI 登录(${file} 不存在);请先运行 agy 并完成 Google 账号登录,或执行 agy remote-control start 保持登录态刷新`);
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new AgyCredentialError(`Antigravity 凭据文件损坏(${file} 不是有效 JSON);请在 agy 内退出登录后重新登录以重建该文件`);
|
|
61
|
+
}
|
|
62
|
+
const root = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
63
|
+
? parsed : undefined;
|
|
64
|
+
const tokenRaw = root?.token;
|
|
65
|
+
const token = tokenRaw !== null && tokenRaw !== undefined && typeof tokenRaw === "object" && !Array.isArray(tokenRaw)
|
|
66
|
+
? tokenRaw : undefined;
|
|
67
|
+
const accessToken = typeof token?.access_token === "string" ? token.access_token : "";
|
|
68
|
+
if (!token || !accessToken) {
|
|
69
|
+
throw new AgyCredentialError(`Antigravity 凭据缺少 access_token(${file});请运行 agy 重新登录后重试`);
|
|
70
|
+
}
|
|
71
|
+
const seed = typeof token.refresh_token === "string" && token.refresh_token
|
|
72
|
+
? token.refresh_token
|
|
73
|
+
: typeof root?.id_token === "string" && root.id_token ? root.id_token : accessToken;
|
|
74
|
+
return {
|
|
75
|
+
accessToken,
|
|
76
|
+
expiryMs: parseExpiry(token.expiry),
|
|
77
|
+
identity: createHash("sha256").update(seed).digest("hex"),
|
|
78
|
+
authMethod: typeof root?.auth_method === "string" && root.auth_method ? root.auth_method : "consumer",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** access_token 是否已过期待判定:过期即快速失败,网关不做任何刷新。 */
|
|
82
|
+
export function agyTokenStale(credentials, now = Date.now()) {
|
|
83
|
+
return credentials.expiryMs > 0 && now >= credentials.expiryMs - EXPIRY_SKEW_MS;
|
|
84
|
+
}
|