@koreanhole/opencodex 2.96.0 → 2.98.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/AGENTS_INSTALL.md +18 -4
- package/README.md +86 -7
- package/package.json +6 -1
- package/src/adapters/cli-agent/acp-client.ts +60 -3
- package/src/adapters/cli-agent/cline/acp.ts +1 -7
- package/src/adapters/cli-agent/cline/adapter.ts +54 -151
- package/src/adapters/cli-agent/cline/settings.ts +38 -260
- package/src/adapters/cli-agent/cline/stream.ts +2 -216
- package/src/adapters/cli-agent/gemini/acp.ts +168 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +211 -17
- package/src/adapters/cli-agent/gemini/live-models.ts +55 -6
- package/src/adapters/cli-agent/gemini/model-metadata.ts +387 -0
- package/src/adapters/cli-agent/gemini/runtime.ts +183 -0
- package/src/adapters/cli-agent/gemini/session-store.ts +202 -0
- package/src/adapters/cli-agent/protocol.ts +60 -9
- package/src/adapters/cli-agent/types.ts +1 -22
- package/src/cli/index.ts +43 -1
- package/src/lib/codex-catalog.ts +351 -0
- package/src/server/index.ts +49 -8
- package/src/server/responses.ts +120 -14
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +25 -1
- package/src/adapters/cli-agent/turn.ts +0 -307
|
@@ -12,6 +12,13 @@ import {
|
|
|
12
12
|
probeGeminiAcpSupport,
|
|
13
13
|
stageGeminiAcpWorkspace,
|
|
14
14
|
} from "./acp";
|
|
15
|
+
import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
|
|
16
|
+
import {
|
|
17
|
+
getGeminiRuntime,
|
|
18
|
+
recordGeminiCompressionThreshold,
|
|
19
|
+
recordGeminiInitialize,
|
|
20
|
+
} from "./runtime";
|
|
21
|
+
import type { OcxProviderModel } from "../../../types";
|
|
15
22
|
|
|
16
23
|
export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "auth_required" | "backoff" | "transport";
|
|
17
24
|
|
|
@@ -19,9 +26,11 @@ export type GeminiLiveModelsResult =
|
|
|
19
26
|
| {
|
|
20
27
|
ok: true;
|
|
21
28
|
models: string[];
|
|
29
|
+
modelInfo: OcxProviderModel[];
|
|
22
30
|
source: "models" | "default";
|
|
23
31
|
fingerprint: string;
|
|
24
32
|
stale?: true;
|
|
33
|
+
metadataWarning?: string;
|
|
25
34
|
}
|
|
26
35
|
| {
|
|
27
36
|
ok: false;
|
|
@@ -34,16 +43,20 @@ export interface GeminiLiveModelsOptions {
|
|
|
34
43
|
binary?: string;
|
|
35
44
|
env?: NodeJS.ProcessEnv;
|
|
36
45
|
deps?: AcpDeps;
|
|
46
|
+
packageRoot?: string;
|
|
37
47
|
ttlMs?: number;
|
|
48
|
+
forceRefresh?: boolean;
|
|
38
49
|
failureBackoffMs?: number;
|
|
39
50
|
now?: () => number;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
interface CacheEntry {
|
|
43
54
|
models: string[];
|
|
55
|
+
modelInfo: OcxProviderModel[];
|
|
44
56
|
source: "models" | "default";
|
|
45
57
|
fetchedAt: number;
|
|
46
58
|
lastFailureAt?: number;
|
|
59
|
+
metadataWarning?: string;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
const cache = new Map<string, CacheEntry>();
|
|
@@ -59,6 +72,10 @@ function staleResult(fingerprint: string, entry: CacheEntry | undefined): Gemini
|
|
|
59
72
|
return {
|
|
60
73
|
ok: true,
|
|
61
74
|
models: [...entry.models],
|
|
75
|
+
modelInfo: entry.modelInfo.map(model => ({
|
|
76
|
+
...model,
|
|
77
|
+
supportedReasoningLevels: [...(model.supportedReasoningLevels ?? [])],
|
|
78
|
+
})),
|
|
62
79
|
source: entry.source,
|
|
63
80
|
fingerprint,
|
|
64
81
|
stale: true,
|
|
@@ -79,8 +96,8 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
79
96
|
const fingerprint = geminiBinaryFingerprintSync(binary);
|
|
80
97
|
const current = cache.get(fingerprint);
|
|
81
98
|
const timestamp = now();
|
|
82
|
-
if (current && timestamp - current.fetchedAt < ttlMs) {
|
|
83
|
-
return { ok: true, models: [...current.models], source: current.source, fingerprint };
|
|
99
|
+
if (!options.forceRefresh && current && timestamp - current.fetchedAt < ttlMs) {
|
|
100
|
+
return { ok: true, models: [...current.models], modelInfo: current.modelInfo, source: current.source, fingerprint, ...(current.metadataWarning ? { metadataWarning: current.metadataWarning } : {}) };
|
|
84
101
|
}
|
|
85
102
|
const lastFailure = failureCache.get(fingerprint) ?? current?.lastFailureAt;
|
|
86
103
|
if (lastFailure !== undefined && timestamp - lastFailure < failureBackoffMs) {
|
|
@@ -96,8 +113,12 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
96
113
|
return staleResult(fingerprint, current)
|
|
97
114
|
?? { ok: false, reason: "unsupported", detail: "Gemini --help does not advertise --acp.", fingerprint };
|
|
98
115
|
}
|
|
99
|
-
const workspace = await stageGeminiAcpWorkspace();
|
|
100
|
-
const sessionEnv = {
|
|
116
|
+
const workspace = await stageGeminiAcpWorkspace([], undefined, { sourceEnv });
|
|
117
|
+
const sessionEnv = {
|
|
118
|
+
...env,
|
|
119
|
+
GEMINI_CLI_HOME: workspace.geminiHome,
|
|
120
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
|
|
121
|
+
};
|
|
101
122
|
let client: AcpClient | undefined;
|
|
102
123
|
try {
|
|
103
124
|
client = await AcpClient.connect({
|
|
@@ -108,12 +129,40 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
108
129
|
label: "Gemini ACP",
|
|
109
130
|
fallbackAllowed: false,
|
|
110
131
|
}, options.deps);
|
|
132
|
+
if (client.initializeResult) await recordGeminiInitialize(fingerprint, client.initializeResult, sourceEnv);
|
|
111
133
|
const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
|
|
112
134
|
const models = geminiModelIds(session);
|
|
135
|
+
const metadata = await discoverGeminiModelMetadata({
|
|
136
|
+
binary,
|
|
137
|
+
env: sourceEnv,
|
|
138
|
+
...(options.packageRoot ? { packageRoot: options.packageRoot } : {}),
|
|
139
|
+
...(options.now ? { now: options.now } : {}),
|
|
140
|
+
...(options.forceRefresh ? { ttlMs: 0 } : {}),
|
|
141
|
+
});
|
|
142
|
+
await recordGeminiCompressionThreshold(fingerprint, metadata.compressionThreshold, sourceEnv);
|
|
143
|
+
const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
|
|
144
|
+
const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata).map(model => {
|
|
145
|
+
const observed = runtime.models[model.id];
|
|
146
|
+
const contextWindow = observed?.contextSize ?? model.contextWindowTokens;
|
|
147
|
+
return {
|
|
148
|
+
...model,
|
|
149
|
+
...(contextWindow !== undefined
|
|
150
|
+
? { contextWindowTokens: contextWindow, maxContextWindowTokens: contextWindow }
|
|
151
|
+
: {}),
|
|
152
|
+
...(runtime.compressionThreshold !== undefined
|
|
153
|
+
? { compressionThreshold: runtime.compressionThreshold }
|
|
154
|
+
: {}),
|
|
155
|
+
inputModalities: [
|
|
156
|
+
"text" as const,
|
|
157
|
+
...(runtime.prompt.image ? ["image" as const] : []),
|
|
158
|
+
],
|
|
159
|
+
loadSession: runtime.loadSession,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
113
162
|
const source = session.availableModels.length > 0 ? "models" : "default";
|
|
114
|
-
cache.set(fingerprint, { models, source, fetchedAt: timestamp });
|
|
163
|
+
cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
|
|
115
164
|
failureCache.delete(fingerprint);
|
|
116
|
-
return { ok: true, models, source, fingerprint };
|
|
165
|
+
return { ok: true, models, modelInfo, source, fingerprint, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) };
|
|
117
166
|
} catch (error) {
|
|
118
167
|
failureCache.set(fingerprint, timestamp);
|
|
119
168
|
if (current) current.lastFailureAt = timestamp;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import type { OcxProviderModel, OcxReasoningLevel } from "../../../types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_METADATA_TTL_MS = 5 * 60_000;
|
|
7
|
+
|
|
8
|
+
export interface GeminiThinkingConfig {
|
|
9
|
+
thinkingLevel?: string;
|
|
10
|
+
thinkingBudget?: number;
|
|
11
|
+
includeThoughts?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiReasoningLevel extends OcxReasoningLevel {
|
|
15
|
+
thinkingConfig: GeminiThinkingConfig;
|
|
16
|
+
originalEffort: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GeminiModelCapability {
|
|
20
|
+
thinkingSupported: boolean;
|
|
21
|
+
reasoningLevels: GeminiReasoningLevel[];
|
|
22
|
+
defaultReasoningLevel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeminiMetadataResult {
|
|
26
|
+
packageRoot?: string;
|
|
27
|
+
settingsPath?: string;
|
|
28
|
+
capabilities: Record<string, GeminiModelCapability>;
|
|
29
|
+
tokenLimits: Record<string, number>;
|
|
30
|
+
defaultTokenLimit?: number;
|
|
31
|
+
compressionThreshold?: number;
|
|
32
|
+
warning?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GeminiMetadataOptions {
|
|
36
|
+
binary: string;
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
packageRoot?: string;
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CachedMetadata extends GeminiMetadataResult {
|
|
44
|
+
fetchedAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const metadataCache = new Map<string, CachedMetadata>();
|
|
48
|
+
|
|
49
|
+
export function resetGeminiMetadataCacheForTests(): void {
|
|
50
|
+
metadataCache.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
55
|
+
? value as Record<string, unknown>
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stringValue(value: unknown): string | undefined {
|
|
60
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractDefaultJsonValue(markdown: string, property: string): unknown {
|
|
64
|
+
const marker = `- **\`modelConfigs.${property}\`**`;
|
|
65
|
+
const start = markdown.indexOf(marker);
|
|
66
|
+
if (start < 0) return undefined;
|
|
67
|
+
const fenceStart = markdown.indexOf("```json", start);
|
|
68
|
+
if (fenceStart < 0) return undefined;
|
|
69
|
+
const bodyStart = markdown.indexOf("\n", fenceStart) + 1;
|
|
70
|
+
const fenceEnd = markdown.indexOf("```", bodyStart);
|
|
71
|
+
if (bodyStart <= 0 || fenceEnd < 0) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(markdown.slice(bodyStart, fenceEnd).trim()) as unknown;
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractDefaultJson(markdown: string, property: string): Record<string, unknown> | undefined {
|
|
80
|
+
return asRecord(extractDefaultJsonValue(markdown, property));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function findPackageRoot(binary: string): Promise<string | undefined> {
|
|
84
|
+
let current: string;
|
|
85
|
+
try {
|
|
86
|
+
current = dirname(await realpath(binary));
|
|
87
|
+
} catch {
|
|
88
|
+
current = dirname(resolve(binary));
|
|
89
|
+
}
|
|
90
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
91
|
+
try {
|
|
92
|
+
const manifest = JSON.parse(await readFile(join(current, "package.json"), "utf8")) as { name?: unknown };
|
|
93
|
+
if (manifest.name === "@google/gemini-cli") return current;
|
|
94
|
+
} catch {
|
|
95
|
+
// Keep walking toward the package root.
|
|
96
|
+
}
|
|
97
|
+
const parent = dirname(current);
|
|
98
|
+
if (parent === current) break;
|
|
99
|
+
current = parent;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort: string; original: string }>> {
|
|
105
|
+
let files: string[];
|
|
106
|
+
try {
|
|
107
|
+
files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const candidates = await Promise.all(files.map(async name => {
|
|
112
|
+
const path = join(packageRoot, "bundle", name);
|
|
113
|
+
const info = await stat(path).catch(() => undefined);
|
|
114
|
+
return info ? { path, size: info.size } : undefined;
|
|
115
|
+
}));
|
|
116
|
+
candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
if (!candidate) continue;
|
|
119
|
+
const text = await readFile(candidate.path, "utf8").catch(() => "");
|
|
120
|
+
if (!text.includes("ThinkingLevel2[\"LOW\"]")) continue;
|
|
121
|
+
const levels = [...text.matchAll(/ThinkingLevel2\["([A-Z0-9_]+)"\]/g)]
|
|
122
|
+
.map(match => match[1]!)
|
|
123
|
+
.filter(level => level !== "THINKING_LEVEL_UNSPECIFIED");
|
|
124
|
+
return [...new Set(levels)].map(level => ({ effort: level.toLowerCase(), original: level }));
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface RuntimeLimits {
|
|
130
|
+
tokenLimits: Record<string, number>;
|
|
131
|
+
defaultTokenLimit?: number;
|
|
132
|
+
compressionThreshold?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function numericLiteral(value: string): number | undefined {
|
|
136
|
+
const parsed = Number(value);
|
|
137
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function functionBody(text: string, name: string): string | undefined {
|
|
141
|
+
const start = text.indexOf(`function ${name}(`);
|
|
142
|
+
if (start < 0) return undefined;
|
|
143
|
+
const open = text.indexOf("{", start);
|
|
144
|
+
if (open < 0) return undefined;
|
|
145
|
+
let depth = 0;
|
|
146
|
+
for (let index = open; index < text.length; index++) {
|
|
147
|
+
if (text[index] === "{") depth++;
|
|
148
|
+
else if (text[index] === "}" && --depth === 0) return text.slice(open + 1, index);
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readRuntimeLimits(packageRoot: string): Promise<RuntimeLimits> {
|
|
154
|
+
let files: string[];
|
|
155
|
+
try {
|
|
156
|
+
files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
|
|
157
|
+
} catch {
|
|
158
|
+
return { tokenLimits: {} };
|
|
159
|
+
}
|
|
160
|
+
const candidates = await Promise.all(files.map(async name => {
|
|
161
|
+
const path = join(packageRoot, "bundle", name);
|
|
162
|
+
const info = await stat(path).catch(() => undefined);
|
|
163
|
+
return info ? { path, size: info.size } : undefined;
|
|
164
|
+
}));
|
|
165
|
+
candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
|
|
166
|
+
for (const candidate of candidates) {
|
|
167
|
+
if (!candidate) continue;
|
|
168
|
+
const text = await readFile(candidate.path, "utf8").catch(() => "");
|
|
169
|
+
if (!text.includes("DEFAULT_TOKEN_LIMIT")) continue;
|
|
170
|
+
const defaultMatch = text.match(/var DEFAULT_TOKEN_LIMIT = ([0-9.e+]+);/);
|
|
171
|
+
const defaultTokenLimit = defaultMatch ? numericLiteral(defaultMatch[1]!) : undefined;
|
|
172
|
+
if (!defaultTokenLimit) continue;
|
|
173
|
+
const gemmaMatch = text.match(/var GEMMA_4_TOKEN_LIMIT = ([0-9.e+]+);/);
|
|
174
|
+
const gemmaLimit = gemmaMatch ? numericLiteral(gemmaMatch[1]!) : undefined;
|
|
175
|
+
const thresholdMatch = text.match(/var DEFAULT_COMPRESSION_TOKEN_THRESHOLD = ([0-9.]+);/);
|
|
176
|
+
const compressionThreshold = thresholdMatch ? Number(thresholdMatch[1]) : undefined;
|
|
177
|
+
const constants = new Map<string, string>();
|
|
178
|
+
for (const match of text.matchAll(/var ([A-Z][A-Z0-9_]*) = "([^"]+)";/g)) {
|
|
179
|
+
constants.set(match[1]!, match[2]!);
|
|
180
|
+
}
|
|
181
|
+
const body = functionBody(text, "tokenLimit");
|
|
182
|
+
const tokenLimits: Record<string, number> = {};
|
|
183
|
+
if (body) {
|
|
184
|
+
const groups = [...body.matchAll(/case ([A-Z][A-Z0-9_]*):([\s\S]*?)(?=case |default:|$)/g)];
|
|
185
|
+
for (const group of groups) {
|
|
186
|
+
const modelId = constants.get(group[1]!);
|
|
187
|
+
const returned = group[2]!.match(/return ([A-Z][A-Z0-9_]*|[0-9.e+]+);/)?.[1];
|
|
188
|
+
if (!modelId || !returned) continue;
|
|
189
|
+
const limit = returned === "DEFAULT_TOKEN_LIMIT"
|
|
190
|
+
? defaultTokenLimit
|
|
191
|
+
: returned === "GEMMA_4_TOKEN_LIMIT"
|
|
192
|
+
? gemmaLimit
|
|
193
|
+
: numericLiteral(returned);
|
|
194
|
+
if (limit) tokenLimits[modelId] = limit;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
tokenLimits,
|
|
199
|
+
defaultTokenLimit,
|
|
200
|
+
...(compressionThreshold !== undefined && compressionThreshold > 0 && compressionThreshold <= 1
|
|
201
|
+
? { compressionThreshold }
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { tokenLimits: {} };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
209
|
+
const out = { ...target };
|
|
210
|
+
for (const [key, value] of Object.entries(source)) {
|
|
211
|
+
const targetRecord = asRecord(out[key]);
|
|
212
|
+
const sourceRecord = asRecord(value);
|
|
213
|
+
out[key] = targetRecord && sourceRecord ? mergeConfig(targetRecord, sourceRecord) : value;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveAlias(
|
|
219
|
+
id: string,
|
|
220
|
+
aliases: Record<string, unknown>,
|
|
221
|
+
seen = new Set<string>(),
|
|
222
|
+
): Record<string, unknown> {
|
|
223
|
+
if (seen.has(id)) return {};
|
|
224
|
+
seen.add(id);
|
|
225
|
+
const alias = asRecord(aliases[id]);
|
|
226
|
+
if (!alias) return {};
|
|
227
|
+
const parent = stringValue(alias.extends);
|
|
228
|
+
const inherited = parent ? resolveAlias(parent, aliases, seen) : {};
|
|
229
|
+
return mergeConfig(inherited, asRecord(alias.modelConfig) ?? {});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function capabilitiesFromMetadata(
|
|
233
|
+
definitions: Record<string, unknown>,
|
|
234
|
+
aliases: Record<string, unknown>,
|
|
235
|
+
levels: Array<{ effort: string; original: string }>,
|
|
236
|
+
overrides: readonly unknown[],
|
|
237
|
+
): Record<string, GeminiModelCapability> {
|
|
238
|
+
const capabilities: Record<string, GeminiModelCapability> = {};
|
|
239
|
+
for (const [modelId, rawDefinition] of Object.entries(definitions)) {
|
|
240
|
+
const definition = asRecord(rawDefinition);
|
|
241
|
+
const features = asRecord(definition?.features);
|
|
242
|
+
const thinkingSupported = features?.thinking === true;
|
|
243
|
+
if (!thinkingSupported) {
|
|
244
|
+
capabilities[modelId] = { thinkingSupported: false, reasoningLevels: [] };
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
let generateContentConfig = asRecord(resolveAlias(modelId, aliases).generateContentConfig) ?? {};
|
|
248
|
+
for (const rawOverride of overrides) {
|
|
249
|
+
const override = asRecord(rawOverride);
|
|
250
|
+
if (stringValue(asRecord(override?.match)?.model) !== modelId) continue;
|
|
251
|
+
generateContentConfig = mergeConfig(generateContentConfig, asRecord(asRecord(override?.modelConfig)?.generateContentConfig) ?? {});
|
|
252
|
+
}
|
|
253
|
+
const config = asRecord(generateContentConfig.thinkingConfig);
|
|
254
|
+
const configuredLevel = stringValue(config?.thinkingLevel)?.toLowerCase();
|
|
255
|
+
const includeThoughts = config?.includeThoughts === true;
|
|
256
|
+
const reasoningLevels = levels.map(level => ({
|
|
257
|
+
effort: level.effort,
|
|
258
|
+
originalEffort: level.original,
|
|
259
|
+
description: `Gemini CLI ${level.original} thinking level`,
|
|
260
|
+
thinkingConfig: {
|
|
261
|
+
thinkingLevel: level.original,
|
|
262
|
+
...(includeThoughts ? { includeThoughts: true } : {}),
|
|
263
|
+
},
|
|
264
|
+
}));
|
|
265
|
+
const defaultReasoningLevel = reasoningLevels.some(level => level.effort === configuredLevel)
|
|
266
|
+
? configuredLevel
|
|
267
|
+
: reasoningLevels.find(level => level.effort === "high")?.effort ?? reasoningLevels[0]?.effort;
|
|
268
|
+
capabilities[modelId] = {
|
|
269
|
+
thinkingSupported: true,
|
|
270
|
+
reasoningLevels,
|
|
271
|
+
...(defaultReasoningLevel ? { defaultReasoningLevel } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return capabilities;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
278
|
+
const env = options.env ?? process.env;
|
|
279
|
+
const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
|
|
280
|
+
if (!packageRoot) return { capabilities: {}, tokenLimits: {}, warning: "Gemini CLI package metadata was not found." };
|
|
281
|
+
const runtimeLimits = await readRuntimeLimits(packageRoot);
|
|
282
|
+
const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
|
|
283
|
+
let markdown: string;
|
|
284
|
+
try {
|
|
285
|
+
markdown = await readFile(docsPath, "utf8");
|
|
286
|
+
} catch {
|
|
287
|
+
return { packageRoot, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI model configuration documentation was not found." };
|
|
288
|
+
}
|
|
289
|
+
const defaultAliases = extractDefaultJson(markdown, "aliases");
|
|
290
|
+
const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
|
|
291
|
+
const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
|
|
292
|
+
if (!defaultAliases || !defaultDefinitions) {
|
|
293
|
+
return { packageRoot, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI model configuration metadata could not be parsed." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
|
|
297
|
+
const settingsPath = join(home, ".gemini", "settings.json");
|
|
298
|
+
let userSettings: Record<string, unknown> = {};
|
|
299
|
+
try {
|
|
300
|
+
userSettings = asRecord(JSON.parse(await readFile(settingsPath, "utf8"))) ?? {};
|
|
301
|
+
} catch {
|
|
302
|
+
// User settings are optional; absence is not a metadata failure.
|
|
303
|
+
}
|
|
304
|
+
const modelConfigs = asRecord(userSettings.modelConfigs) ?? {};
|
|
305
|
+
const aliases = mergeConfig(
|
|
306
|
+
mergeConfig(defaultAliases, asRecord(modelConfigs.customAliases) ?? {}),
|
|
307
|
+
asRecord(modelConfigs.aliases) ?? {},
|
|
308
|
+
);
|
|
309
|
+
const definitions = mergeConfig(defaultDefinitions, asRecord(modelConfigs.modelDefinitions) ?? {});
|
|
310
|
+
const overrides = [
|
|
311
|
+
...(Array.isArray(defaultOverrides) ? defaultOverrides : []),
|
|
312
|
+
...(Array.isArray(modelConfigs.overrides) ? modelConfigs.overrides : []),
|
|
313
|
+
...(Array.isArray(modelConfigs.customOverrides) ? modelConfigs.customOverrides : []),
|
|
314
|
+
];
|
|
315
|
+
const levels = await readThinkingLevels(packageRoot);
|
|
316
|
+
if (levels.length === 0) {
|
|
317
|
+
return { packageRoot, settingsPath, capabilities: {}, ...runtimeLimits, warning: "Gemini CLI thinking-level metadata was not found." };
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
packageRoot,
|
|
321
|
+
settingsPath,
|
|
322
|
+
capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
|
|
323
|
+
...runtimeLimits,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function discoverGeminiModelMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
328
|
+
const env = options.env ?? process.env;
|
|
329
|
+
const key = [
|
|
330
|
+
options.packageRoot ?? "auto",
|
|
331
|
+
resolve(options.binary),
|
|
332
|
+
stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir(),
|
|
333
|
+
].join("\n");
|
|
334
|
+
const now = options.now ?? Date.now;
|
|
335
|
+
const ttlMs = options.ttlMs ?? DEFAULT_METADATA_TTL_MS;
|
|
336
|
+
const cached = metadataCache.get(key);
|
|
337
|
+
if (cached && now() - cached.fetchedAt < ttlMs) return cached;
|
|
338
|
+
const result = await loadMetadata(options);
|
|
339
|
+
const entry: CachedMetadata = { ...result, fetchedAt: now() };
|
|
340
|
+
metadataCache.set(key, entry);
|
|
341
|
+
return entry;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function mergeGeminiModelDescriptors(
|
|
345
|
+
advertised: readonly { modelId: string; name?: string; description?: string }[],
|
|
346
|
+
metadata: GeminiMetadataResult,
|
|
347
|
+
): OcxProviderModel[] {
|
|
348
|
+
return advertised.map(model => {
|
|
349
|
+
const capability = metadata.capabilities[model.modelId];
|
|
350
|
+
const reasoningLevels = capability?.reasoningLevels.map(level => ({
|
|
351
|
+
effort: level.effort,
|
|
352
|
+
description: level.description,
|
|
353
|
+
})) ?? [];
|
|
354
|
+
return {
|
|
355
|
+
id: model.modelId,
|
|
356
|
+
...(model.name ? { name: model.name } : {}),
|
|
357
|
+
...(model.description ? { description: model.description } : {}),
|
|
358
|
+
supportedReasoningLevels: reasoningLevels,
|
|
359
|
+
...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
|
|
360
|
+
...(metadata.tokenLimits[model.modelId] !== undefined
|
|
361
|
+
? {
|
|
362
|
+
contextWindowTokens: metadata.tokenLimits[model.modelId],
|
|
363
|
+
maxContextWindowTokens: metadata.tokenLimits[model.modelId],
|
|
364
|
+
}
|
|
365
|
+
: metadata.defaultTokenLimit !== undefined
|
|
366
|
+
? {
|
|
367
|
+
contextWindowTokens: metadata.defaultTokenLimit,
|
|
368
|
+
maxContextWindowTokens: metadata.defaultTokenLimit,
|
|
369
|
+
}
|
|
370
|
+
: {}),
|
|
371
|
+
...(metadata.compressionThreshold !== undefined ? { compressionThreshold: metadata.compressionThreshold } : {}),
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function reasoningLevelForEffort(
|
|
377
|
+
capability: GeminiModelCapability | undefined,
|
|
378
|
+
effort: string | undefined,
|
|
379
|
+
): GeminiReasoningLevel | undefined {
|
|
380
|
+
if (!capability?.thinkingSupported || capability.reasoningLevels.length === 0) return undefined;
|
|
381
|
+
const wanted = effort?.trim().toLowerCase();
|
|
382
|
+
if (!wanted) {
|
|
383
|
+
return capability.reasoningLevels.find(level => level.effort === capability.defaultReasoningLevel)
|
|
384
|
+
?? capability.reasoningLevels[0];
|
|
385
|
+
}
|
|
386
|
+
return capability.reasoningLevels.find(level => level.effort === wanted);
|
|
387
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../../../config";
|
|
4
|
+
import type { AcpInitialize } from "../acp-client";
|
|
5
|
+
|
|
6
|
+
export interface GeminiModelRuntime {
|
|
7
|
+
contextSize?: number;
|
|
8
|
+
usedTokens?: number;
|
|
9
|
+
promptChars?: number;
|
|
10
|
+
charsPerToken?: number;
|
|
11
|
+
observedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiRuntime {
|
|
15
|
+
loadSession: boolean;
|
|
16
|
+
prompt: {
|
|
17
|
+
image: boolean;
|
|
18
|
+
audio: boolean;
|
|
19
|
+
embeddedContext: boolean;
|
|
20
|
+
};
|
|
21
|
+
compressionThreshold?: number;
|
|
22
|
+
models: Record<string, GeminiModelRuntime>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const runtimeCache = new Map<string, GeminiRuntime>();
|
|
26
|
+
let loadedFromDisk = false;
|
|
27
|
+
|
|
28
|
+
function positive(value: unknown): number | undefined {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nonNegative(value: unknown): number | undefined {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runtimePath(env: NodeJS.ProcessEnv): string {
|
|
37
|
+
return join(getConfigDir(env), "gemini", "capabilities.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function emptyRuntime(): GeminiRuntime {
|
|
41
|
+
return {
|
|
42
|
+
loadSession: false,
|
|
43
|
+
prompt: { image: false, audio: false, embeddedContext: false },
|
|
44
|
+
models: {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRuntime(value: unknown): GeminiRuntime | undefined {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
50
|
+
const row = value as Record<string, unknown>;
|
|
51
|
+
const prompt = row.prompt && typeof row.prompt === "object" && !Array.isArray(row.prompt)
|
|
52
|
+
? row.prompt as Record<string, unknown>
|
|
53
|
+
: {};
|
|
54
|
+
const models: Record<string, GeminiModelRuntime> = {};
|
|
55
|
+
if (row.models && typeof row.models === "object" && !Array.isArray(row.models)) {
|
|
56
|
+
for (const [modelId, raw] of Object.entries(row.models as Record<string, unknown>)) {
|
|
57
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
58
|
+
const item = raw as Record<string, unknown>;
|
|
59
|
+
models[modelId] = {
|
|
60
|
+
...(positive(item.contextSize) !== undefined ? { contextSize: positive(item.contextSize) } : {}),
|
|
61
|
+
...(nonNegative(item.usedTokens) !== undefined ? { usedTokens: nonNegative(item.usedTokens) } : {}),
|
|
62
|
+
...(nonNegative(item.promptChars) !== undefined ? { promptChars: nonNegative(item.promptChars) } : {}),
|
|
63
|
+
...(positive(item.charsPerToken) !== undefined ? { charsPerToken: positive(item.charsPerToken) } : {}),
|
|
64
|
+
observedAt: nonNegative(item.observedAt) ?? 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const threshold = positive(row.compressionThreshold);
|
|
69
|
+
return {
|
|
70
|
+
loadSession: row.loadSession === true,
|
|
71
|
+
prompt: {
|
|
72
|
+
image: prompt.image === true,
|
|
73
|
+
audio: prompt.audio === true,
|
|
74
|
+
embeddedContext: prompt.embeddedContext === true,
|
|
75
|
+
},
|
|
76
|
+
...(threshold !== undefined && threshold <= 1 ? { compressionThreshold: threshold } : {}),
|
|
77
|
+
models,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readDisk(env: NodeJS.ProcessEnv): Promise<void> {
|
|
82
|
+
if (loadedFromDisk) return;
|
|
83
|
+
loadedFromDisk = true;
|
|
84
|
+
let parsed: unknown;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(await readFile(runtimePath(env), "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
91
|
+
for (const [fingerprint, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
92
|
+
const runtime = parseRuntime(value);
|
|
93
|
+
if (runtime) runtimeCache.set(fingerprint, runtime);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function persist(env: NodeJS.ProcessEnv): Promise<void> {
|
|
98
|
+
const path = runtimePath(env);
|
|
99
|
+
await mkdir(join(getConfigDir(env), "gemini"), { recursive: true, mode: 0o700 });
|
|
100
|
+
const limit = Number(env.OPENCODEX_GEMINI_CAPABILITY_MAX ?? "") || 8;
|
|
101
|
+
if (runtimeCache.size > limit) {
|
|
102
|
+
const oldest = [...runtimeCache.entries()]
|
|
103
|
+
.sort((left, right) => {
|
|
104
|
+
const leftAt = Math.max(0, ...Object.values(left[1].models).map(model => model.observedAt));
|
|
105
|
+
const rightAt = Math.max(0, ...Object.values(right[1].models).map(model => model.observedAt));
|
|
106
|
+
return leftAt - rightAt;
|
|
107
|
+
});
|
|
108
|
+
for (const [fingerprint] of oldest.slice(0, Math.max(0, runtimeCache.size - limit))) {
|
|
109
|
+
runtimeCache.delete(fingerprint);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const payload = Object.fromEntries(runtimeCache);
|
|
113
|
+
const temporary = `${path}.${crypto.randomUUID()}.tmp`;
|
|
114
|
+
await writeFile(temporary, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
115
|
+
await rename(temporary, path);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getGeminiRuntime(
|
|
119
|
+
fingerprint: string,
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
): Promise<GeminiRuntime> {
|
|
122
|
+
await readDisk(env);
|
|
123
|
+
return runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function recordGeminiInitialize(
|
|
127
|
+
fingerprint: string,
|
|
128
|
+
initialize: AcpInitialize,
|
|
129
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
await readDisk(env);
|
|
132
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
133
|
+
runtimeCache.set(fingerprint, {
|
|
134
|
+
...current,
|
|
135
|
+
loadSession: initialize.capabilities.loadSession,
|
|
136
|
+
prompt: { ...initialize.capabilities.prompt },
|
|
137
|
+
});
|
|
138
|
+
await persist(env).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function recordGeminiUsage(
|
|
142
|
+
fingerprint: string,
|
|
143
|
+
modelId: string,
|
|
144
|
+
usedTokens: number,
|
|
145
|
+
contextSize: number,
|
|
146
|
+
promptChars: number,
|
|
147
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
if (!positive(contextSize) || !nonNegative(usedTokens) || !nonNegative(promptChars)) return;
|
|
150
|
+
await readDisk(env);
|
|
151
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
152
|
+
const previous = current.models[modelId];
|
|
153
|
+
const charsPerToken = usedTokens > 0 && promptChars > 0
|
|
154
|
+
? promptChars / usedTokens
|
|
155
|
+
: previous?.charsPerToken;
|
|
156
|
+
current.models[modelId] = {
|
|
157
|
+
contextSize,
|
|
158
|
+
usedTokens,
|
|
159
|
+
promptChars,
|
|
160
|
+
...(charsPerToken !== undefined ? { charsPerToken } : {}),
|
|
161
|
+
observedAt: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
runtimeCache.set(fingerprint, current);
|
|
164
|
+
await persist(env).catch(() => undefined);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function recordGeminiCompressionThreshold(
|
|
168
|
+
fingerprint: string,
|
|
169
|
+
threshold: number | undefined,
|
|
170
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (threshold === undefined || !positive(threshold) || threshold > 1) return;
|
|
173
|
+
await readDisk(env);
|
|
174
|
+
const current = runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
175
|
+
current.compressionThreshold = threshold;
|
|
176
|
+
runtimeCache.set(fingerprint, current);
|
|
177
|
+
await persist(env).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resetGeminiRuntimeCacheForTests(): void {
|
|
181
|
+
runtimeCache.clear();
|
|
182
|
+
loadedFromDisk = false;
|
|
183
|
+
}
|