jeo-code 0.9.10 → 0.9.12
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/CHANGELOG.md +30 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +2 -1
- package/src/agent/gjc-session-import.ts +1632 -0
- package/src/agent/session.ts +48 -7
- package/src/ai/model-cache.ts +57 -20
- package/src/ai/model-catalog.ts +93 -36
- package/src/ai/model-discovery.ts +31 -4
- package/src/commands/launch/input.ts +38 -6
- package/src/commands/launch/jobs-slash.ts +66 -0
- package/src/commands/launch/session-slash.ts +146 -2
- package/src/commands/launch.ts +29 -4
- package/src/tui/components/input-box.ts +47 -10
- package/src/tui/components/slash.ts +3 -1
- package/src/tui/components/welcome.ts +2 -2
- package/src/types/playwriter.d.ts +15 -0
- package/src/util/file-attachment.ts +26 -6
package/src/agent/session.ts
CHANGED
|
@@ -14,18 +14,25 @@ export interface SessionHeader {
|
|
|
14
14
|
* its own model independent of the global default (per-session model selection). */
|
|
15
15
|
model?: string;
|
|
16
16
|
/** Unsent input-box draft at last save, restored into the prompt on `/resume`
|
|
17
|
-
* (gajae-code parity: an in-progress
|
|
17
|
+
* (gajae-code parity: an in-progress cursor survives a quit + resume instead
|
|
18
18
|
* of being silently lost). Absent/empty when there was nothing to restore. */
|
|
19
19
|
draft?: string;
|
|
20
|
-
|
|
20
|
+
/** Source system marker for imported sessions (read-only session importer layer). */
|
|
21
|
+
sourceSystem?: "gjc";
|
|
22
|
+
/** Source session id for imported sessions. */
|
|
23
|
+
sourceSessionId?: string;
|
|
24
|
+
/** Source leaf id for imported sessions. */
|
|
25
|
+
sourceLeafId?: string;
|
|
26
|
+
/** SHA-256 hex of the exact imported source bytes. */
|
|
27
|
+
sourceSha256?: string;
|
|
28
|
+
/** Import timestamp for imported sessions (ISO-8601 UTC string). */
|
|
29
|
+
importTimestamp?: string;
|
|
21
30
|
}
|
|
22
|
-
|
|
23
31
|
export interface SessionEntry {
|
|
24
32
|
type: "message";
|
|
25
33
|
timestamp: string;
|
|
26
34
|
message: Message;
|
|
27
35
|
}
|
|
28
|
-
|
|
29
36
|
export interface CompactionEntry {
|
|
30
37
|
type: "compaction";
|
|
31
38
|
timestamp: string;
|
|
@@ -45,6 +52,19 @@ export interface SessionSummary {
|
|
|
45
52
|
title?: string;
|
|
46
53
|
/** Model id pinned to this session (header `model`), if any. */
|
|
47
54
|
model?: string;
|
|
55
|
+
/** GJC import provenance (present for imported sessions). */
|
|
56
|
+
sourceSystem?: "gjc";
|
|
57
|
+
sourceSessionId?: string;
|
|
58
|
+
sourceLeafId?: string;
|
|
59
|
+
sourceSha256?: string;
|
|
60
|
+
importTimestamp?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ImportedSessionLookup {
|
|
64
|
+
sourceSystem: "gjc";
|
|
65
|
+
sourceSessionId: string;
|
|
66
|
+
sourceLeafId: string;
|
|
67
|
+
sourceSha256: string;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export const SESSION_VERSION = 1;
|
|
@@ -183,15 +203,15 @@ export async function loadSession(
|
|
|
183
203
|
if (compactions.length > 0) {
|
|
184
204
|
const lastComp = compactions[compactions.length - 1];
|
|
185
205
|
const { summary, replacesThrough } = lastComp;
|
|
186
|
-
|
|
206
|
+
|
|
187
207
|
const hasSystem = rawMessages.length > 0 && rawMessages[0].role === "system";
|
|
188
208
|
const systemPrompt = hasSystem ? [rawMessages[0]] : [];
|
|
189
|
-
|
|
209
|
+
|
|
190
210
|
const summaryMessage: Message = {
|
|
191
211
|
role: "user",
|
|
192
212
|
content: `[Earlier conversation summary]\n${summary}`,
|
|
193
213
|
};
|
|
194
|
-
|
|
214
|
+
|
|
195
215
|
const remaining = rawMessages.slice(replacesThrough + 1);
|
|
196
216
|
messages = [...systemPrompt, summaryMessage, ...remaining];
|
|
197
217
|
}
|
|
@@ -305,6 +325,11 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
|
|
|
305
325
|
sizeBytes: stat.size,
|
|
306
326
|
title: header.title,
|
|
307
327
|
model: header.model,
|
|
328
|
+
sourceSystem: header.sourceSystem,
|
|
329
|
+
sourceSessionId: header.sourceSessionId,
|
|
330
|
+
sourceLeafId: header.sourceLeafId,
|
|
331
|
+
sourceSha256: header.sourceSha256,
|
|
332
|
+
importTimestamp: header.importTimestamp,
|
|
308
333
|
});
|
|
309
334
|
} catch {
|
|
310
335
|
// Tolerate malformed files (skip them)
|
|
@@ -348,6 +373,22 @@ export async function resolveSessionRef(idOrPrefix: string, cwd = process.cwd())
|
|
|
348
373
|
return { kind: "not-found" };
|
|
349
374
|
}
|
|
350
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Locate an imported session by exact provenance match.
|
|
378
|
+
*/
|
|
379
|
+
export async function findImportedSessionByProvenance(
|
|
380
|
+
cwd = process.cwd(),
|
|
381
|
+
lookup: ImportedSessionLookup,
|
|
382
|
+
): Promise<SessionSummary | undefined> {
|
|
383
|
+
const all = await listSessions(cwd);
|
|
384
|
+
return all.find(s =>
|
|
385
|
+
s.sourceSystem === lookup.sourceSystem &&
|
|
386
|
+
s.sourceSessionId === lookup.sourceSessionId &&
|
|
387
|
+
s.sourceLeafId === lookup.sourceLeafId &&
|
|
388
|
+
s.sourceSha256 === lookup.sourceSha256
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
351
392
|
/**
|
|
352
393
|
* Locate the session's JSONL header, apply `mutate`, and rewrite the file in place.
|
|
353
394
|
* `mutate` returns false to signal "no change needed" (skips the write). Shared by
|
package/src/ai/model-cache.ts
CHANGED
|
@@ -18,7 +18,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
18
18
|
import * as os from "node:os";
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { jeoEnv } from "../util/env";
|
|
21
|
-
import { recordLiveCodexModels, recordLiveProviderModels } from "./model-catalog";
|
|
21
|
+
import { recordLiveCodexModels, recordLiveProviderModels, setOpenAIOauthAccountScope } from "./model-catalog";
|
|
22
|
+
import { getStoredOAuth } from "../auth/storage";
|
|
23
|
+
import { extractChatgptAccountId } from "./providers/openai-responses";
|
|
22
24
|
import type { ProviderName } from "./types";
|
|
23
25
|
|
|
24
26
|
/** How a provider's list was obtained; mirrors `ProviderModelsResult["source"]`. */
|
|
@@ -30,15 +32,17 @@ export interface CachedProviderModels {
|
|
|
30
32
|
source: ModelCacheSource;
|
|
31
33
|
/** Base URL the list came from, so an OpenAI-compatible endpoint's ids stay scoped to it. */
|
|
32
34
|
baseUrl?: string;
|
|
35
|
+
/** Stable OpenAI OAuth account scope for this row; required for account-specific rehydration. */
|
|
36
|
+
accountId?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface ModelCacheFile {
|
|
36
|
-
version:
|
|
40
|
+
version: 2;
|
|
37
41
|
updatedAt: number;
|
|
38
42
|
providers: CachedProviderModels[];
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
const CACHE_VERSION =
|
|
45
|
+
const CACHE_VERSION = 2;
|
|
42
46
|
/** Refresh in the background once the cache is older than this (6h). */
|
|
43
47
|
export const MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
44
48
|
/** Bound a single provider's persisted list so a pathological aggregator cannot bloat the file. */
|
|
@@ -52,7 +56,6 @@ function cachePath(): string {
|
|
|
52
56
|
return path.join(cacheDir(), "model-catalog-cache.json");
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
/** Keep only well-formed entries; a hand-edited or partially-written file must not throw. */
|
|
56
59
|
export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
|
|
57
60
|
if (!Array.isArray(raw)) return [];
|
|
58
61
|
const out: CachedProviderModels[] = [];
|
|
@@ -61,11 +64,18 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
|
|
|
61
64
|
if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
|
|
62
65
|
const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, MAX_MODELS_PER_PROVIDER);
|
|
63
66
|
if (models.length === 0) continue;
|
|
67
|
+
const provider = row.provider as ProviderName;
|
|
68
|
+
const source: ModelCacheSource =
|
|
69
|
+
row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
|
|
70
|
+
const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
|
|
71
|
+
const accountId = typeof row.accountId === "string" ? row.accountId.trim() : "";
|
|
72
|
+
if (provider === "openai" && source === "oauth" && !accountId) continue;
|
|
64
73
|
out.push({
|
|
65
|
-
provider
|
|
74
|
+
provider,
|
|
66
75
|
models,
|
|
67
|
-
source
|
|
68
|
-
...(
|
|
76
|
+
source,
|
|
77
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
78
|
+
...(provider === "openai" && source === "oauth" ? { accountId } : {}),
|
|
69
79
|
});
|
|
70
80
|
}
|
|
71
81
|
return out;
|
|
@@ -101,18 +111,29 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
|
|
|
101
111
|
*/
|
|
102
112
|
export function mergeCacheEntries(
|
|
103
113
|
previous: readonly CachedProviderModels[],
|
|
104
|
-
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string }[],
|
|
114
|
+
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
|
|
105
115
|
): CachedProviderModels[] {
|
|
106
116
|
const byKey = new Map<string, CachedProviderModels>();
|
|
107
|
-
const keyOf = (provider: string, baseUrl?: string) =>
|
|
108
|
-
|
|
117
|
+
const keyOf = (provider: string, baseUrl?: string, accountId?: string) =>
|
|
118
|
+
`${provider}\u0000${baseUrl ?? ""}\u0000${accountId ?? ""}`;
|
|
119
|
+
for (const entry of previous) {
|
|
120
|
+
const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
|
|
121
|
+
if (isOpenAIOAuth && !entry.accountId) continue;
|
|
122
|
+
byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined), entry);
|
|
123
|
+
}
|
|
109
124
|
for (const result of results) {
|
|
110
125
|
if (!result.ok || result.models.length === 0) continue;
|
|
111
|
-
|
|
126
|
+
const source: ModelCacheSource = result.source ?? "none";
|
|
127
|
+
const isOpenAIOAuth = result.provider === "openai" && source === "oauth";
|
|
128
|
+
const baseUrl = typeof result.baseUrl === "string" ? result.baseUrl.replace(/\/+$/, "") : undefined;
|
|
129
|
+
const accountId = typeof result.accountId === "string" ? result.accountId.trim() : "";
|
|
130
|
+
if (isOpenAIOAuth && !accountId) continue;
|
|
131
|
+
byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined), {
|
|
112
132
|
provider: result.provider,
|
|
113
133
|
models: [...result.models].slice(0, MAX_MODELS_PER_PROVIDER),
|
|
114
|
-
source
|
|
115
|
-
...(
|
|
134
|
+
source,
|
|
135
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
136
|
+
...(isOpenAIOAuth ? { accountId } : {}),
|
|
116
137
|
});
|
|
117
138
|
}
|
|
118
139
|
return [...byKey.values()];
|
|
@@ -120,7 +141,7 @@ export function mergeCacheEntries(
|
|
|
120
141
|
|
|
121
142
|
/** Persist discovery results (best-effort; never throws, never blocks a turn). */
|
|
122
143
|
export async function writeModelCache(
|
|
123
|
-
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string }[],
|
|
144
|
+
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
|
|
124
145
|
): Promise<void> {
|
|
125
146
|
// Same hermeticity rule as saveGlobalConfig: a test run may only write into an
|
|
126
147
|
// explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
|
|
@@ -142,14 +163,21 @@ export async function writeModelCache(
|
|
|
142
163
|
* routing, and — critically — the OAuth Codex gate know the account's real models
|
|
143
164
|
* BEFORE the first network call of this launch. Returns the number of ids applied.
|
|
144
165
|
*/
|
|
145
|
-
export function applyCachedModels(cache: ModelCacheFile | null): number {
|
|
166
|
+
export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccountId?: string): number {
|
|
146
167
|
if (!cache) return 0;
|
|
168
|
+
const accountId = typeof openAIOauthAccountId === "string" ? openAIOauthAccountId.trim() : "";
|
|
147
169
|
let applied = 0;
|
|
148
170
|
for (const entry of cache.providers) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
|
|
172
|
+
if (isOpenAIOAuth && (!accountId || entry.accountId !== accountId)) continue;
|
|
173
|
+
recordLiveProviderModels(entry.provider, entry.models, {
|
|
174
|
+
source: entry.source,
|
|
175
|
+
baseUrl: entry.baseUrl,
|
|
176
|
+
...(isOpenAIOAuth && entry.accountId ? { accountId: entry.accountId } : {}),
|
|
177
|
+
});
|
|
178
|
+
if (isOpenAIOAuth && entry.accountId) {
|
|
179
|
+
recordLiveCodexModels(entry.models, entry.accountId);
|
|
180
|
+
}
|
|
153
181
|
applied += entry.models.length;
|
|
154
182
|
}
|
|
155
183
|
return applied;
|
|
@@ -158,6 +186,15 @@ export function applyCachedModels(cache: ModelCacheFile | null): number {
|
|
|
158
186
|
/** Read + apply in one step. Returns the cache so callers can decide about refreshing. */
|
|
159
187
|
export async function rehydrateLiveModels(): Promise<ModelCacheFile | null> {
|
|
160
188
|
const cache = await readModelCache();
|
|
161
|
-
|
|
189
|
+
let accountId: string | undefined;
|
|
190
|
+
try {
|
|
191
|
+
const stored = await getStoredOAuth("openai");
|
|
192
|
+
const jwtAccountId = stored ? extractChatgptAccountId(stored.access) : undefined;
|
|
193
|
+
accountId = jwtAccountId || stored?.accountId;
|
|
194
|
+
} catch {
|
|
195
|
+
accountId = undefined;
|
|
196
|
+
}
|
|
197
|
+
setOpenAIOauthAccountScope(accountId);
|
|
198
|
+
applyCachedModels(cache, accountId);
|
|
162
199
|
return cache;
|
|
163
200
|
}
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -186,42 +186,75 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
186
186
|
export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Session-lifetime
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* successful discovery this session widens the gate immediately, no release needed;
|
|
197
|
-
* a fresh process starts empty and falls back to the static list until the first
|
|
198
|
-
* discovery call (picker open, `jeo doctor`, or model resolution) populates it.
|
|
189
|
+
* Session-lifetime Codex model observations. The maintained `CODEX_MODELS` list
|
|
190
|
+
* above is deliberately static; live-only ids are accepted only after the
|
|
191
|
+
* current OAuth account's Codex Responses model list confirms them.
|
|
192
|
+
*
|
|
193
|
+
* An identity-less observation uses the ephemeral scope and is never persisted.
|
|
194
|
+
* Account changes evict prior OAuth observations so a model from one account
|
|
195
|
+
* cannot authorize a call for another account.
|
|
199
196
|
*/
|
|
200
|
-
const
|
|
197
|
+
const EPHEMERAL_OPENAI_OAUTH_SCOPE = "__jeo-openai-oauth-ephemeral__";
|
|
198
|
+
let activeOpenAIOauthScope = EPHEMERAL_OPENAI_OAUTH_SCOPE;
|
|
199
|
+
const liveCodexModels = new Map<string, Set<string>>();
|
|
200
|
+
|
|
201
|
+
function normalizeOpenAIOauthScope(scope: string | undefined): string {
|
|
202
|
+
return typeof scope === "string" ? scope.trim() : "";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function oauthScope(scope: string | undefined): string {
|
|
206
|
+
return normalizeOpenAIOauthScope(scope) || EPHEMERAL_OPENAI_OAUTH_SCOPE;
|
|
207
|
+
}
|
|
201
208
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
function removeLiveProviderRecord(recordKey: string, record: LiveProviderModelRecord): void {
|
|
210
|
+
liveProviderModels.delete(recordKey);
|
|
211
|
+
for (const id of [record.row.canonical, record.row.providerModel]) {
|
|
212
|
+
const indexKey = liveIndexKey(record.row.provider, id);
|
|
213
|
+
const bucket = liveModelIdIndex.get(indexKey);
|
|
214
|
+
if (!bucket) continue;
|
|
215
|
+
bucket.delete(recordKey);
|
|
216
|
+
if (bucket.size === 0) liveModelIdIndex.delete(indexKey);
|
|
217
|
+
}
|
|
207
218
|
}
|
|
208
219
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
/** Select the current OAuth account and evict every other account's live rows. */
|
|
221
|
+
export function setOpenAIOauthAccountScope(accountId: string | undefined): void {
|
|
222
|
+
const nextScope = oauthScope(accountId);
|
|
223
|
+
activeOpenAIOauthScope = nextScope;
|
|
224
|
+
for (const scope of [...liveCodexModels.keys()]) {
|
|
225
|
+
if (scope !== nextScope) liveCodexModels.delete(scope);
|
|
226
|
+
}
|
|
227
|
+
for (const [recordKey, record] of liveProviderModels.entries()) {
|
|
228
|
+
if (record.row.provider === "openai" && record.source === "oauth" && oauthScope(record.accountId) !== nextScope) {
|
|
229
|
+
removeLiveProviderRecord(recordKey, record);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Record ids confirmed by the current account's Codex Responses endpoint. */
|
|
235
|
+
export function recordLiveCodexModels(ids: readonly string[], accountId?: string): void {
|
|
236
|
+
const explicitScope = normalizeOpenAIOauthScope(accountId);
|
|
237
|
+
if (explicitScope) setOpenAIOauthAccountScope(explicitScope);
|
|
238
|
+
const scope = explicitScope || EPHEMERAL_OPENAI_OAUTH_SCOPE;
|
|
239
|
+
const bucket = liveCodexModels.get(scope) ?? new Set<string>();
|
|
240
|
+
for (const id of ids) {
|
|
241
|
+
const trimmed = id.trim();
|
|
242
|
+
if (trimmed) bucket.add(trimmed);
|
|
243
|
+
}
|
|
244
|
+
liveCodexModels.set(scope, bucket);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True for the static Codex snapshot or a live id confirmed for the active scope. */
|
|
212
248
|
export function isCodexModel(model: string): boolean {
|
|
213
249
|
const wire = model.startsWith("openai/") ? model.slice(7) : model;
|
|
214
|
-
return CODEX_MODELS.includes(wire) || liveCodexModels.has(wire);
|
|
250
|
+
return CODEX_MODELS.includes(wire) || (liveCodexModels.get(activeOpenAIOauthScope)?.has(wire) ?? false);
|
|
215
251
|
}
|
|
216
252
|
|
|
217
|
-
/** Test-only
|
|
218
|
-
* / `resetAppearanceCache`). Bun runs test files in one process, so this module-level Set
|
|
219
|
-
* would otherwise leak an `oauth`-source `listProviderModels("openai", …)` call's observed
|
|
220
|
-
* ids into unrelated tests in the same run. */
|
|
253
|
+
/** Test-only reset for live Codex observations and the active account scope. */
|
|
221
254
|
export function resetLiveCodexModels(): void {
|
|
222
255
|
liveCodexModels.clear();
|
|
256
|
+
activeOpenAIOauthScope = EPHEMERAL_OPENAI_OAUTH_SCOPE;
|
|
223
257
|
}
|
|
224
|
-
|
|
225
258
|
/** Live provider model ids observed from authenticated/keyless `/models` endpoints during
|
|
226
259
|
* this process. Unlike `liveCodexModels`, this is NOT an OAuth allow-list; it is a
|
|
227
260
|
* routing supplement so API-key and OpenAI-compatible catalogs discovered at runtime
|
|
@@ -231,6 +264,7 @@ interface LiveProviderModelRecord {
|
|
|
231
264
|
row: CatalogModel;
|
|
232
265
|
source: LiveProviderModelSource;
|
|
233
266
|
baseUrl?: string;
|
|
267
|
+
accountId?: string;
|
|
234
268
|
}
|
|
235
269
|
const liveProviderModels = new Map<string, LiveProviderModelRecord>();
|
|
236
270
|
|
|
@@ -291,8 +325,14 @@ function liveCanonicalId(provider: ProviderName, id: string): string {
|
|
|
291
325
|
: trimmed;
|
|
292
326
|
}
|
|
293
327
|
|
|
294
|
-
function liveModelRecordKey(
|
|
295
|
-
|
|
328
|
+
function liveModelRecordKey(
|
|
329
|
+
provider: ProviderName,
|
|
330
|
+
canonical: string,
|
|
331
|
+
baseUrl: string | undefined,
|
|
332
|
+
accountId?: string,
|
|
333
|
+
): string {
|
|
334
|
+
const normalizedAccountId = provider === "openai" ? normalizeOpenAIOauthScope(accountId) : "";
|
|
335
|
+
return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${canonical}`;
|
|
296
336
|
}
|
|
297
337
|
|
|
298
338
|
function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
|
|
@@ -316,9 +356,16 @@ function liveModelFallbackRow(provider: ProviderName, canonical: string, provide
|
|
|
316
356
|
export function recordLiveProviderModels(
|
|
317
357
|
provider: ProviderName,
|
|
318
358
|
ids: readonly string[],
|
|
319
|
-
opts: { source?: LiveProviderModelSource; baseUrl?: string } = {},
|
|
359
|
+
opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string } = {},
|
|
320
360
|
): void {
|
|
361
|
+
const source = opts.source ?? "none";
|
|
321
362
|
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
|
363
|
+
const scopedAccountId = provider === "openai" && source === "oauth"
|
|
364
|
+
? normalizeOpenAIOauthScope(opts.accountId) || undefined
|
|
365
|
+
: undefined;
|
|
366
|
+
if (provider === "openai" && source === "oauth" && scopedAccountId) {
|
|
367
|
+
setOpenAIOauthAccountScope(scopedAccountId);
|
|
368
|
+
}
|
|
322
369
|
for (const id of ids) {
|
|
323
370
|
const canonical = liveCanonicalId(provider, id);
|
|
324
371
|
if (!canonical) continue;
|
|
@@ -327,26 +374,34 @@ export function recordLiveProviderModels(
|
|
|
327
374
|
const row = known
|
|
328
375
|
? { ...known, canonical, provider, providerModel }
|
|
329
376
|
: liveModelFallbackRow(provider, canonical, providerModel);
|
|
330
|
-
const recordKey = liveModelRecordKey(provider, canonical, baseUrl);
|
|
377
|
+
const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId);
|
|
331
378
|
liveProviderModels.set(recordKey, {
|
|
332
379
|
row,
|
|
333
|
-
source
|
|
380
|
+
source,
|
|
334
381
|
baseUrl,
|
|
382
|
+
...(scopedAccountId ? { accountId: scopedAccountId } : {}),
|
|
335
383
|
});
|
|
336
384
|
addToLiveIndex(provider, canonical, recordKey);
|
|
337
385
|
addToLiveIndex(provider, providerModel, recordKey);
|
|
338
386
|
}
|
|
339
387
|
}
|
|
340
388
|
|
|
341
|
-
function liveRecordMatchesConfig(
|
|
389
|
+
function liveRecordMatchesConfig(
|
|
390
|
+
record: LiveProviderModelRecord,
|
|
391
|
+
config?: { openaiBaseUrl?: string; openaiOauthScope?: string },
|
|
392
|
+
): boolean {
|
|
342
393
|
if (record.row.provider !== "openai") return true;
|
|
343
394
|
const wanted = normalizeBaseUrl(config?.openaiBaseUrl);
|
|
344
|
-
if (wanted || record.baseUrl)
|
|
345
|
-
|
|
395
|
+
if (wanted || record.baseUrl) {
|
|
396
|
+
if (wanted !== record.baseUrl) return false;
|
|
397
|
+
}
|
|
398
|
+
if (record.source !== "oauth") return true;
|
|
399
|
+
const wantedScope = oauthScope(config?.openaiOauthScope ?? activeOpenAIOauthScope);
|
|
400
|
+
return oauthScope(record.accountId) === wantedScope;
|
|
346
401
|
}
|
|
347
402
|
|
|
348
403
|
/** Live-discovered catalog rows usable for routing under the current config. */
|
|
349
|
-
export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }): CatalogModel[] {
|
|
404
|
+
export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string; openaiOauthScope?: string }): CatalogModel[] {
|
|
350
405
|
return [...liveProviderModels.values()]
|
|
351
406
|
.filter(record => liveRecordMatchesConfig(record, config))
|
|
352
407
|
.map(record => record.row);
|
|
@@ -363,7 +418,9 @@ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }):
|
|
|
363
418
|
* large), so a linear scan is cheap — same access pattern as `liveProviderCatalogModels`. */
|
|
364
419
|
export function findLiveCatalogModel(canonical: string): CatalogModel | undefined {
|
|
365
420
|
for (const record of liveProviderModels.values()) {
|
|
366
|
-
if (record.row.canonical
|
|
421
|
+
if (record.row.canonical !== canonical) continue;
|
|
422
|
+
if (record.row.provider === "openai" && record.source === "oauth" && !liveRecordMatchesConfig(record)) continue;
|
|
423
|
+
return record.row;
|
|
367
424
|
}
|
|
368
425
|
return undefined;
|
|
369
426
|
}
|
|
@@ -372,7 +429,7 @@ export function findLiveCatalogModel(canonical: string): CatalogModel | undefine
|
|
|
372
429
|
* O(1) average case via `liveModelIdIndex` — see that Map's doc comment for why
|
|
373
430
|
* this must not be an O(n) scan over every live-discovered model (it runs once
|
|
374
431
|
* per STATIC catalog row inside `isAutoSelectCandidate`). */
|
|
375
|
-
export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
|
|
432
|
+
export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string; openaiOauthScope?: string }): boolean {
|
|
376
433
|
const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
|
|
377
434
|
for (const candidate of candidates) {
|
|
378
435
|
const recordKeys = liveModelIdIndex.get(liveIndexKey(provider, candidate));
|
|
@@ -11,8 +11,16 @@ import { readGlobalConfig, type Config } from "../agent/state";
|
|
|
11
11
|
import { resolveCredential, type AuthProvider, type Credential } from "../auth";
|
|
12
12
|
import type { ProviderName } from "./types";
|
|
13
13
|
import { PROVIDER_NAMES } from "./provider-status";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
catalogByProvider,
|
|
16
|
+
CODEX_MODELS,
|
|
17
|
+
KIMI_CODE_MODELS,
|
|
18
|
+
recordLiveCodexModels,
|
|
19
|
+
recordLiveProviderModels,
|
|
20
|
+
setOpenAIOauthAccountScope,
|
|
21
|
+
} from "./model-catalog";
|
|
15
22
|
import { extractChatgptAccountId } from "./providers/openai-responses";
|
|
23
|
+
import { getStoredOAuth } from "../auth/storage";
|
|
16
24
|
import { openaiCompatDef } from "./providers/openai-compatible-catalog";
|
|
17
25
|
|
|
18
26
|
export interface ProviderModelsResult {
|
|
@@ -22,6 +30,8 @@ export interface ProviderModelsResult {
|
|
|
22
30
|
ok: boolean;
|
|
23
31
|
/** How the request authenticated (for display). */
|
|
24
32
|
source: "oauth" | "api_key" | "keyless" | "none";
|
|
33
|
+
/** Stable OpenAI OAuth account scope for account-specific cache rows. */
|
|
34
|
+
accountId?: string;
|
|
25
35
|
/** Present on failure: a short, human-readable reason. */
|
|
26
36
|
error?: string;
|
|
27
37
|
/** True when the live endpoint was unusable and ids came from the static catalog. */
|
|
@@ -334,6 +344,7 @@ export async function listProviderModels(
|
|
|
334
344
|
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
335
345
|
|
|
336
346
|
let cred: Credential | undefined;
|
|
347
|
+
let oauthAccountId: string | undefined;
|
|
337
348
|
let source: ProviderModelsResult["source"] = "keyless";
|
|
338
349
|
if (provider === "xai") {
|
|
339
350
|
// xAI (Grok) is API-key only and not an OAuth AuthProvider: resolve its key
|
|
@@ -376,6 +387,12 @@ export async function listProviderModels(
|
|
|
376
387
|
cred = { kind: "api_key", provider: prov, token: config.providers[prov]! };
|
|
377
388
|
source = "api_key";
|
|
378
389
|
}
|
|
390
|
+
if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
|
|
391
|
+
const stored = await getStoredOAuth("openai").catch(() => undefined);
|
|
392
|
+
const jwtAccountId = extractChatgptAccountId(cred.token);
|
|
393
|
+
oauthAccountId = jwtAccountId || stored?.accountId;
|
|
394
|
+
setOpenAIOauthAccountScope(oauthAccountId);
|
|
395
|
+
}
|
|
379
396
|
// Gemini models are exposed ONLY under API-key auth: a gemini OAuth token can
|
|
380
397
|
// still list models from generativelanguage.googleapis.com, but it can no
|
|
381
398
|
// longer SERVE them (the gemini-cli/Cloud Code Assist masquerade was removed
|
|
@@ -419,9 +436,19 @@ export async function listProviderModels(
|
|
|
419
436
|
// Record live ids for routing supplements. For OpenAI OAuth, keep the
|
|
420
437
|
// existing Codex allow-list widening separate: API-key/custom-base discovery
|
|
421
438
|
// must not make OAuth Codex calls accept unrelated API models.
|
|
422
|
-
recordLiveProviderModels(provider, models, {
|
|
423
|
-
|
|
424
|
-
|
|
439
|
+
recordLiveProviderModels(provider, models, {
|
|
440
|
+
source,
|
|
441
|
+
baseUrl: opts.baseUrl,
|
|
442
|
+
...(provider === "openai" && source === "oauth" && oauthAccountId ? { accountId: oauthAccountId } : {}),
|
|
443
|
+
});
|
|
444
|
+
if (provider === "openai" && source === "oauth") recordLiveCodexModels(models, oauthAccountId);
|
|
445
|
+
return {
|
|
446
|
+
provider,
|
|
447
|
+
models,
|
|
448
|
+
ok: true,
|
|
449
|
+
source,
|
|
450
|
+
...(oauthAccountId ? { accountId: oauthAccountId } : {}),
|
|
451
|
+
};
|
|
425
452
|
} catch (err) {
|
|
426
453
|
const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
|
|
427
454
|
return { provider, models: [], ok: false, source, error: msg };
|
|
@@ -384,17 +384,35 @@ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolea
|
|
|
384
384
|
return accepted;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
387
|
+
/** Pasted body captured while a TURN is running: pure DATA appended verbatim to the
|
|
388
|
+
* draft the box will show when the turn ends.
|
|
389
|
+
*
|
|
390
|
+
* Line breaks fold to {@link MULTILINE_SENTINEL} — the SAME contract the idle key
|
|
391
|
+
* filter uses — so a pasted code block / stack trace keeps its structure (and its
|
|
392
|
+
* indentation) instead of being flattened into one space-joined line. `multiline:
|
|
393
|
+
* false` (JEO_NO_MULTILINE=1, no sentinel expansion on submit) degrades to a single
|
|
394
|
+
* space so the sentinel can never leak into the model's input.
|
|
395
|
+
*
|
|
396
|
+
* Nothing is trimmed and no separator is inserted between chunks: a large paste is
|
|
397
|
+
* delivered over SEVERAL stdin reads, and the old per-chunk `trim()` + `join(" ")`
|
|
398
|
+
* both dropped leading indentation and spliced a stray space into whatever word the
|
|
399
|
+
* read boundary happened to split ("hello wo" + "rld" → "hello wo rld"). */
|
|
400
|
+
function feedLivePromptPasteBody(state: PromptInputQueue, body: string, multiline: boolean): boolean {
|
|
388
401
|
if (!body) return false;
|
|
389
402
|
const normalized = stripPasteEscapes(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
390
|
-
const
|
|
391
|
-
if (!
|
|
392
|
-
state.partial
|
|
403
|
+
const folded = normalized.split("\n").join(multiline ? MULTILINE_SENTINEL : " ");
|
|
404
|
+
if (!folded) return false;
|
|
405
|
+
state.partial += folded;
|
|
393
406
|
return true;
|
|
394
407
|
}
|
|
395
408
|
|
|
396
|
-
export function captureLivePromptInputChunk(
|
|
409
|
+
export function captureLivePromptInputChunk(
|
|
410
|
+
state: PromptInputQueue,
|
|
411
|
+
chunk: string,
|
|
412
|
+
opts: { multiline?: boolean } = {},
|
|
413
|
+
): boolean {
|
|
397
414
|
if (!chunk) return false;
|
|
415
|
+
const multiline = opts.multiline !== false;
|
|
398
416
|
let accepted = false;
|
|
399
417
|
let rest = chunk;
|
|
400
418
|
while (rest.length > 0) {
|
|
@@ -403,7 +421,7 @@ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: stri
|
|
|
403
421
|
const body = end === -1 ? rest : rest.slice(0, end);
|
|
404
422
|
if (end !== -1) state.inPaste = false;
|
|
405
423
|
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
406
|
-
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
424
|
+
if (feedLivePromptPasteBody(state, body, multiline)) accepted = true;
|
|
407
425
|
} else {
|
|
408
426
|
const start = rest.indexOf(PASTE_START);
|
|
409
427
|
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
@@ -863,6 +881,7 @@ export function filterPromptInputChunk(
|
|
|
863
881
|
// or a `\r\n` straddling this read boundary — is never half-interpreted. Carrying is
|
|
864
882
|
// safe: the concatenation is re-parsed next call, so a tail that turns out NOT to be a
|
|
865
883
|
// marker is simply emitted one chunk later.
|
|
884
|
+
const carriedEsc = state.carry === "\u001b";
|
|
866
885
|
data = (state.carry ?? "") + data;
|
|
867
886
|
state.carry = "";
|
|
868
887
|
const markerTail = pasteMarkerTailLength(data);
|
|
@@ -890,6 +909,19 @@ export function filterPromptInputChunk(
|
|
|
890
909
|
let out = "";
|
|
891
910
|
let i = 0;
|
|
892
911
|
while (i < data.length) {
|
|
912
|
+
// A lone Esc keystroke that the PREVIOUS chunk carried, now followed by bytes that
|
|
913
|
+
// cannot continue an escape sequence (`ESC [` / `ESC O`): drop the ESC instead of
|
|
914
|
+
// forwarding it. readline's key decoder otherwise holds a dangling ESC as a pending
|
|
915
|
+
// META prefix and merges it with the NEXT keystroke (Esc, then "z" → meta-z), which
|
|
916
|
+
// SWALLOWS that character — the "Esc 로 지운 뒤 첫 글자가 안 먹힘" bug (a pasted
|
|
917
|
+
// block's first character disappeared the same way). jeo owns Esc itself on the raw
|
|
918
|
+
// process.stdin keypress listener (clearTypedInput), so readline never needs the byte.
|
|
919
|
+
// Only a CARRIED Esc is dropped: an `ESC x` pair that arrived in ONE chunk is a real
|
|
920
|
+
// Alt/Meta chord (Alt+b, Alt+f, …) and still reaches readline untouched.
|
|
921
|
+
if (carriedEsc && i === 0 && data[0] === "\u001b" && (data.length === 1 || (data[1] !== "[" && data[1] !== "O"))) {
|
|
922
|
+
i += 1;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
893
925
|
if (!state.inPaste && data.startsWith(PASTE_START, i)) { state.inPaste = true; out += PASTE_START; i += PASTE_START.length; continue; }
|
|
894
926
|
if (state.inPaste && data.startsWith(PASTE_END, i)) { state.inPaste = false; out += PASTE_END; i += PASTE_END.length; continue; }
|
|
895
927
|
if (state.inPaste) {
|