@koreanhole/opencodex 3.1.0 → 3.2.1
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 +1 -1
- package/README.md +30 -12
- package/package.json +6 -5
- package/src/adapter-runtime/gateway.ts +5 -1
- package/src/adapters/cli-agent/env.ts +2 -2
- package/src/adapters/cli-agent/gemini/adapter.ts +46 -158
- package/src/adapters/cli-agent/gemini/headless.ts +654 -0
- package/src/adapters/cli-agent/gemini/live-models.ts +21 -66
- package/src/adapters/cli-agent/gemini/runtime.ts +1 -62
- package/src/adapters/cli-agent/protocol.ts +5 -4
- package/src/server/index.ts +6 -1
- package/src/server/websocket.ts +5 -0
- package/src/adapters/cli-agent/gemini/acp.ts +0 -793
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AcpClient,
|
|
3
|
-
AcpError,
|
|
4
|
-
type AcpDeps,
|
|
5
|
-
} from "../acp-client";
|
|
6
1
|
import { geminiBinaryFingerprintSync, resolveGeminiBinary } from "./binary";
|
|
7
|
-
import {
|
|
8
|
-
buildGeminiAcpEnv,
|
|
9
|
-
geminiAcpArgs,
|
|
10
|
-
geminiAcpEnabled,
|
|
11
|
-
geminiModelIds,
|
|
12
|
-
probeGeminiAcpSupport,
|
|
13
|
-
stageGeminiAcpWorkspace,
|
|
14
|
-
} from "./acp";
|
|
2
|
+
import { geminiHeadlessEnabled } from "./headless";
|
|
15
3
|
import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
recordGeminiCompressionThreshold,
|
|
19
|
-
recordGeminiInitialize,
|
|
20
|
-
} from "./runtime";
|
|
4
|
+
import { getGeminiRuntime, recordGeminiCompressionThreshold } from "./runtime";
|
|
5
|
+
import type { CliAgentTurnDeps } from "../types";
|
|
21
6
|
import type { OcxProviderModel } from "../../../types";
|
|
22
7
|
|
|
23
|
-
export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "
|
|
8
|
+
export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "backoff" | "transport";
|
|
24
9
|
|
|
25
10
|
export type GeminiLiveModelsResult =
|
|
26
11
|
| {
|
|
@@ -42,7 +27,7 @@ export type GeminiLiveModelsResult =
|
|
|
42
27
|
export interface GeminiLiveModelsOptions {
|
|
43
28
|
binary?: string;
|
|
44
29
|
env?: NodeJS.ProcessEnv;
|
|
45
|
-
deps?:
|
|
30
|
+
deps?: CliAgentTurnDeps;
|
|
46
31
|
packageRoot?: string;
|
|
47
32
|
ttlMs?: number;
|
|
48
33
|
forceRefresh?: boolean;
|
|
@@ -82,14 +67,14 @@ function staleResult(fingerprint: string, entry: CacheEntry | undefined): Gemini
|
|
|
82
67
|
};
|
|
83
68
|
}
|
|
84
69
|
|
|
85
|
-
/** Discover Gemini
|
|
70
|
+
/** Discover the installed Gemini CLI roster from local package metadata. */
|
|
86
71
|
export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions = {}): Promise<GeminiLiveModelsResult> {
|
|
87
72
|
const sourceEnv = options.env ?? process.env;
|
|
88
73
|
const now = options.now ?? Date.now;
|
|
89
74
|
const ttlMs = options.ttlMs ?? 5 * 60_000;
|
|
90
75
|
const failureBackoffMs = options.failureBackoffMs ?? 60_000;
|
|
91
|
-
if (!
|
|
92
|
-
return { ok: false, reason: "unsupported", detail: "
|
|
76
|
+
if (!geminiHeadlessEnabled(sourceEnv)) {
|
|
77
|
+
return { ok: false, reason: "unsupported", detail: "Gemini provider is disabled by OPENCODEX_GEMINI_ACP=0." };
|
|
93
78
|
}
|
|
94
79
|
const binary = options.binary ?? resolveGeminiBinary(undefined, sourceEnv);
|
|
95
80
|
if (!binary) return { ok: false, reason: "not_found", detail: "Gemini CLI not found on PATH." };
|
|
@@ -105,33 +90,7 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
105
90
|
?? { ok: false, reason: "backoff", detail: "Gemini model discovery is cooling down after a failure.", fingerprint };
|
|
106
91
|
}
|
|
107
92
|
|
|
108
|
-
const env = buildGeminiAcpEnv(sourceEnv, options.deps?.platform ?? process.platform);
|
|
109
|
-
const mode = await probeGeminiAcpSupport(binary, env, { ...options.deps, env: sourceEnv });
|
|
110
|
-
if (!mode.acp) {
|
|
111
|
-
failureCache.set(fingerprint, timestamp);
|
|
112
|
-
if (current) current.lastFailureAt = timestamp;
|
|
113
|
-
return staleResult(fingerprint, current)
|
|
114
|
-
?? { ok: false, reason: "unsupported", detail: "Gemini --help does not advertise --acp.", fingerprint };
|
|
115
|
-
}
|
|
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
|
-
};
|
|
122
|
-
let client: AcpClient | undefined;
|
|
123
93
|
try {
|
|
124
|
-
client = await AcpClient.connect({
|
|
125
|
-
binary,
|
|
126
|
-
env: sessionEnv,
|
|
127
|
-
cwd: workspace.cwd,
|
|
128
|
-
args: geminiAcpArgs(workspace.policyPath),
|
|
129
|
-
label: "Gemini ACP",
|
|
130
|
-
fallbackAllowed: false,
|
|
131
|
-
}, options.deps);
|
|
132
|
-
if (client.initializeResult) await recordGeminiInitialize(fingerprint, client.initializeResult, sourceEnv);
|
|
133
|
-
const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
|
|
134
|
-
const models = geminiModelIds(session);
|
|
135
94
|
const metadata = await discoverGeminiModelMetadata({
|
|
136
95
|
binary,
|
|
137
96
|
env: sourceEnv,
|
|
@@ -139,9 +98,16 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
139
98
|
...(options.now ? { now: options.now } : {}),
|
|
140
99
|
...(options.forceRefresh ? { ttlMs: 0 } : {}),
|
|
141
100
|
});
|
|
101
|
+
const ids = Object.keys(metadata.capabilities);
|
|
102
|
+
if (ids.length === 0) {
|
|
103
|
+
failureCache.set(fingerprint, timestamp);
|
|
104
|
+
if (current) current.lastFailureAt = timestamp;
|
|
105
|
+
return staleResult(fingerprint, current)
|
|
106
|
+
?? { ok: false, reason: "transport", detail: metadata.warning ?? "Gemini model metadata was not found.", fingerprint };
|
|
107
|
+
}
|
|
142
108
|
await recordGeminiCompressionThreshold(fingerprint, metadata.compressionThreshold, sourceEnv);
|
|
143
109
|
const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
|
|
144
|
-
const modelInfo = mergeGeminiModelDescriptors(
|
|
110
|
+
const modelInfo = mergeGeminiModelDescriptors(ids.map(modelId => ({ modelId })), metadata).map(model => {
|
|
145
111
|
const observed = runtime.models[model.id];
|
|
146
112
|
const contextWindow = observed?.contextSize ?? model.contextWindowTokens;
|
|
147
113
|
return {
|
|
@@ -152,32 +118,21 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
152
118
|
...(runtime.compressionThreshold !== undefined
|
|
153
119
|
? { compressionThreshold: runtime.compressionThreshold }
|
|
154
120
|
: {}),
|
|
155
|
-
inputModalities: [
|
|
156
|
-
|
|
157
|
-
...(runtime.prompt.image ? ["image" as const] : []),
|
|
158
|
-
],
|
|
159
|
-
loadSession: runtime.loadSession,
|
|
121
|
+
inputModalities: ["text" as const],
|
|
122
|
+
loadSession: false,
|
|
160
123
|
};
|
|
161
124
|
});
|
|
162
|
-
|
|
163
|
-
cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
|
|
125
|
+
cache.set(fingerprint, { models: ids, modelInfo, source: "models", fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
|
|
164
126
|
failureCache.delete(fingerprint);
|
|
165
|
-
return { ok: true, models, modelInfo, source, fingerprint, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) };
|
|
127
|
+
return { ok: true, models: ids, modelInfo, source: "models", fingerprint, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) };
|
|
166
128
|
} catch (error) {
|
|
167
129
|
failureCache.set(fingerprint, timestamp);
|
|
168
130
|
if (current) current.lastFailureAt = timestamp;
|
|
169
|
-
|
|
170
|
-
if (error instanceof AcpError && error.authRequired) {
|
|
171
|
-
return stale ?? { ok: false, reason: "auth_required", detail: error.message, fingerprint };
|
|
172
|
-
}
|
|
173
|
-
return stale ?? {
|
|
131
|
+
return staleResult(fingerprint, current) ?? {
|
|
174
132
|
ok: false,
|
|
175
133
|
reason: "transport",
|
|
176
134
|
detail: error instanceof Error ? error.message : String(error),
|
|
177
135
|
fingerprint,
|
|
178
136
|
};
|
|
179
|
-
} finally {
|
|
180
|
-
await client?.terminate();
|
|
181
|
-
await workspace.cleanup();
|
|
182
137
|
}
|
|
183
138
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getConfigDir } from "../../../config";
|
|
4
|
-
import type { AcpInitialize } from "../acp-client";
|
|
5
4
|
|
|
6
5
|
export interface GeminiModelRuntime {
|
|
7
6
|
contextSize?: number;
|
|
@@ -12,12 +11,6 @@ export interface GeminiModelRuntime {
|
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
export interface GeminiRuntime {
|
|
15
|
-
loadSession: boolean;
|
|
16
|
-
prompt: {
|
|
17
|
-
image: boolean;
|
|
18
|
-
audio: boolean;
|
|
19
|
-
embeddedContext: boolean;
|
|
20
|
-
};
|
|
21
14
|
compressionThreshold?: number;
|
|
22
15
|
models: Record<string, GeminiModelRuntime>;
|
|
23
16
|
}
|
|
@@ -38,19 +31,12 @@ function runtimePath(env: NodeJS.ProcessEnv): string {
|
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
function emptyRuntime(): GeminiRuntime {
|
|
41
|
-
return {
|
|
42
|
-
loadSession: false,
|
|
43
|
-
prompt: { image: false, audio: false, embeddedContext: false },
|
|
44
|
-
models: {},
|
|
45
|
-
};
|
|
34
|
+
return { models: {} };
|
|
46
35
|
}
|
|
47
36
|
|
|
48
37
|
function parseRuntime(value: unknown): GeminiRuntime | undefined {
|
|
49
38
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
50
39
|
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
40
|
const models: Record<string, GeminiModelRuntime> = {};
|
|
55
41
|
if (row.models && typeof row.models === "object" && !Array.isArray(row.models)) {
|
|
56
42
|
for (const [modelId, raw] of Object.entries(row.models as Record<string, unknown>)) {
|
|
@@ -67,12 +53,6 @@ function parseRuntime(value: unknown): GeminiRuntime | undefined {
|
|
|
67
53
|
}
|
|
68
54
|
const threshold = positive(row.compressionThreshold);
|
|
69
55
|
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
56
|
...(threshold !== undefined && threshold <= 1 ? { compressionThreshold: threshold } : {}),
|
|
77
57
|
models,
|
|
78
58
|
};
|
|
@@ -123,47 +103,6 @@ export async function getGeminiRuntime(
|
|
|
123
103
|
return runtimeCache.get(fingerprint) ?? emptyRuntime();
|
|
124
104
|
}
|
|
125
105
|
|
|
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
106
|
export async function recordGeminiCompressionThreshold(
|
|
168
107
|
fingerprint: string,
|
|
169
108
|
threshold: number | undefined,
|
|
@@ -19,6 +19,7 @@ export type StreamMessage = Record<string, unknown>;
|
|
|
19
19
|
export async function* readJsonLines(
|
|
20
20
|
chunks: AsyncIterable<Uint8Array>,
|
|
21
21
|
limits: { maxLineBytes?: number; maxTotalBytes?: number } = {},
|
|
22
|
+
label = "Cline",
|
|
22
23
|
): AsyncGenerator<StreamMessage> {
|
|
23
24
|
const maxLineBytes = limits.maxLineBytes ?? MAX_STREAM_LINE_BYTES;
|
|
24
25
|
const maxTotalBytes = limits.maxTotalBytes ?? MAX_STREAM_TOTAL_BYTES;
|
|
@@ -34,10 +35,10 @@ export async function* readJsonLines(
|
|
|
34
35
|
try {
|
|
35
36
|
parsed = JSON.parse(trimmed);
|
|
36
37
|
} catch {
|
|
37
|
-
throw new CodingAgentProtocolError(`Malformed
|
|
38
|
+
throw new CodingAgentProtocolError(`Malformed ${label} JSON frame: ${trimmed.slice(0, 64)}`);
|
|
38
39
|
}
|
|
39
40
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
40
|
-
throw new CodingAgentProtocolError(`Non-object
|
|
41
|
+
throw new CodingAgentProtocolError(`Non-object ${label} JSON frame: ${trimmed.slice(0, 64)}`);
|
|
41
42
|
}
|
|
42
43
|
yield parsed as StreamMessage;
|
|
43
44
|
};
|
|
@@ -49,7 +50,7 @@ export async function* readJsonLines(
|
|
|
49
50
|
const end = newline < 0 ? text.length : newline;
|
|
50
51
|
const part = text.slice(start, end);
|
|
51
52
|
lineBytes += Buffer.byteLength(part);
|
|
52
|
-
if (lineBytes > maxLineBytes) throw new CodingAgentProtocolError(
|
|
53
|
+
if (lineBytes > maxLineBytes) throw new CodingAgentProtocolError(`${label} stream line exceeded the byte limit`);
|
|
53
54
|
if (part) parts.push(part);
|
|
54
55
|
if (newline < 0) break;
|
|
55
56
|
const line = parts.join("");
|
|
@@ -62,7 +63,7 @@ export async function* readJsonLines(
|
|
|
62
63
|
|
|
63
64
|
for await (const chunk of chunks) {
|
|
64
65
|
totalBytes += chunk.byteLength;
|
|
65
|
-
if (totalBytes > maxTotalBytes) throw new CodingAgentProtocolError(
|
|
66
|
+
if (totalBytes > maxTotalBytes) throw new CodingAgentProtocolError(`${label} stream exceeded the total byte limit`);
|
|
66
67
|
yield* consume(decoder.decode(chunk, { stream: true }));
|
|
67
68
|
}
|
|
68
69
|
yield* consume(decoder.decode());
|
package/src/server/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface ServerOptions {
|
|
|
31
31
|
geminiDeps?: GeminiTurnDeps;
|
|
32
32
|
geminiPackageRoot?: string;
|
|
33
33
|
providerScope?: ProviderId;
|
|
34
|
+
/** Test seam for exercising Bun's idle timeout; production uses 255 seconds. */
|
|
35
|
+
idleTimeoutSeconds?: number;
|
|
34
36
|
onWebSocketRequest?: (observation: WebSocketRequestObservation) => void;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -148,6 +150,9 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
148
150
|
return new Response("WebSocket upgrade failed.", { status: 400 });
|
|
149
151
|
}
|
|
150
152
|
try {
|
|
153
|
+
if (req.method === "POST" && (url.pathname === "/v1/responses" || url.pathname === "/v1/messages")) {
|
|
154
|
+
try { bunServer.timeout(req, 0); } catch { /* request already closed */ }
|
|
155
|
+
}
|
|
151
156
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
152
157
|
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot, options.providerScope);
|
|
153
158
|
return Response.json({ object: "list", data: modelList(known, options.providerScope) });
|
|
@@ -193,7 +198,7 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
193
198
|
server = Bun.serve({
|
|
194
199
|
hostname: config.host,
|
|
195
200
|
port: config.port,
|
|
196
|
-
idleTimeout: 255,
|
|
201
|
+
idleTimeout: options.idleTimeoutSeconds ?? 255,
|
|
197
202
|
fetch,
|
|
198
203
|
websocket: createWebSocketHandlers(websocketContext),
|
|
199
204
|
});
|
package/src/server/websocket.ts
CHANGED
|
@@ -146,6 +146,10 @@ async function forwardStream(
|
|
|
146
146
|
const reader = stream.getReader();
|
|
147
147
|
const decoder = new TextDecoder();
|
|
148
148
|
let buffer = "";
|
|
149
|
+
const ping = setInterval(() => {
|
|
150
|
+
if (ws.data.closed) return;
|
|
151
|
+
try { ws.ping(); } catch { /* client gone */ }
|
|
152
|
+
}, 5_000);
|
|
149
153
|
try {
|
|
150
154
|
while (!ws.data.closed && !signal.aborted) {
|
|
151
155
|
const { done, value } = await reader.read();
|
|
@@ -165,6 +169,7 @@ async function forwardStream(
|
|
|
165
169
|
}
|
|
166
170
|
}
|
|
167
171
|
} finally {
|
|
172
|
+
clearInterval(ping);
|
|
168
173
|
try { await reader.cancel(); } catch { /* client gone */ }
|
|
169
174
|
}
|
|
170
175
|
}
|