@khorsheed/dsh-local-agent-codex 0.2.0-rc.1 → 0.2.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/CHANGELOG.md +25 -0
- package/README.en.md +23 -122
- package/README.i18n.yaml +2 -2
- package/README.md +23 -122
- package/lib/client.js +811 -317
- package/lib/index.js +818 -252
- package/lib/types/client/SettingsCard.d.ts +73 -24
- package/lib/types/client/SettingsCard.js +110 -140
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +6 -26
- package/lib/types/client/index.js +136 -50
- package/lib/types/client/locales.d.ts +34 -24
- package/lib/types/client/locales.js +38 -28
- package/lib/types/codex-cli-provider.d.ts +54 -8
- package/lib/types/codex-cli-provider.js +204 -70
- package/lib/types/endpoint-auth.d.ts +194 -0
- package/lib/types/endpoint-auth.js +300 -0
- package/lib/types/index.d.ts +19 -3
- package/lib/types/index.js +127 -28
- package/lib/types/live-driver.d.ts +9 -5
- package/lib/types/live-driver.js +247 -108
- package/lib/types/live-switch.d.ts +7 -0
- package/lib/types/live-switch.js +1 -0
- package/lib/types/model-broker.d.ts +12 -2
- package/lib/types/model-broker.js +51 -6
- package/lib/types/model-catalog.d.ts +53 -9
- package/lib/types/model-catalog.js +95 -12
- package/lib/types/provision.d.ts +12 -27
- package/lib/types/provision.js +19 -40
- package/lib/types/records.d.ts +6 -11
- package/lib/types/records.js +14 -25
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
|
@@ -39,6 +39,42 @@ function dedupeChoices(layers) {
|
|
|
39
39
|
}
|
|
40
40
|
return choices;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Scope the served directory to the surface asking for it (the same helper
|
|
44
|
+
* lives in each provider's broker — fold into the family core when the
|
|
45
|
+
* directory faces next evolve):
|
|
46
|
+
*
|
|
47
|
+
* - memberless (the card's add-model dialog, the invite picker): the
|
|
48
|
+
* endpoint's served catalog only — the configuration/history extras the
|
|
49
|
+
* broker's info extension adds for STATUS display must never become add
|
|
50
|
+
* candidates;
|
|
51
|
+
* - a member whose harness has a curated list: the list IS the vocabulary,
|
|
52
|
+
* in the list's own order (the same rule `choices` already follows) — a
|
|
53
|
+
* curated value the endpoint no longer serves stays as a bare row (the
|
|
54
|
+
* card shows it the same way), and the member's current effective model is
|
|
55
|
+
* kept visible as a bare entry when it sits outside the list, so a pre-list
|
|
56
|
+
* binding never renders blank;
|
|
57
|
+
* - a member without a curated list: the legacy extended vocabulary.
|
|
58
|
+
*/
|
|
59
|
+
function scopeServedDirectory(directory, opts) {
|
|
60
|
+
if (opts.member && opts.curated.length === 0)
|
|
61
|
+
return directory;
|
|
62
|
+
const served = directory.entries.filter(entry => entry.source === 'native');
|
|
63
|
+
if (!opts.member)
|
|
64
|
+
return { ...directory, entries: served };
|
|
65
|
+
const byValue = new Map(served.flatMap(entry => entry.resolvedModel === undefined
|
|
66
|
+
? [[entry.value, entry]]
|
|
67
|
+
: [[entry.value, entry], [entry.resolvedModel, entry]]));
|
|
68
|
+
// Curated ORDER leads (the card's first row is the default); a curated value
|
|
69
|
+
// the endpoint no longer serves stays listed as a bare row, exactly like the
|
|
70
|
+
// card shows it.
|
|
71
|
+
const entries = opts.curated.map(value => byValue.get(value) ?? { value, label: value, source: 'configuration' });
|
|
72
|
+
const current = opts.current?.trim();
|
|
73
|
+
if (current !== undefined && current !== '' && !entries.some(entry => entry.value === current || entry.resolvedModel === current)) {
|
|
74
|
+
entries.push({ value: current, label: current, source: 'configuration' });
|
|
75
|
+
}
|
|
76
|
+
return { ...directory, entries };
|
|
77
|
+
}
|
|
42
78
|
export class CodexModelBroker {
|
|
43
79
|
deps;
|
|
44
80
|
constructor(deps) {
|
|
@@ -54,7 +90,7 @@ export class CodexModelBroker {
|
|
|
54
90
|
}
|
|
55
91
|
const model = selection.model.mode === 'value' ? selection.model.value
|
|
56
92
|
: (selection.model.mode === 'inherit' ? binding.model : undefined)
|
|
57
|
-
?? this.deps.settingsModel() ?? await readCodexModel(home)
|
|
93
|
+
?? this.deps.settingsModel(home) ?? await readCodexModel(home)
|
|
58
94
|
?? this.deps.catalogDefault(home, binding.cwd);
|
|
59
95
|
const entry = directory?.entries.find(entry => entry.value === model);
|
|
60
96
|
const explicitEffort = selection.effort.mode === 'value' ? selection.effort.value
|
|
@@ -88,7 +124,12 @@ export class CodexModelBroker {
|
|
|
88
124
|
if (refresh)
|
|
89
125
|
await this.deps.refreshDirectory?.(home, this.deps.cwd?.(childSessionId));
|
|
90
126
|
const info = await this.modelInfo(childSessionId, childSessionId === undefined ? undefined : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model);
|
|
91
|
-
|
|
127
|
+
const directory = info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
|
|
128
|
+
return scopeServedDirectory(directory, {
|
|
129
|
+
member: childSessionId !== undefined,
|
|
130
|
+
curated: this.deps.modelList?.(home) ?? [],
|
|
131
|
+
current: info.effective,
|
|
132
|
+
});
|
|
92
133
|
}
|
|
93
134
|
async *followModelDirectory(childSessionId, signal) {
|
|
94
135
|
if (this.deps.followDirectory === undefined) {
|
|
@@ -113,7 +154,7 @@ export class CodexModelBroker {
|
|
|
113
154
|
const recorded = record?.model?.trim();
|
|
114
155
|
if (recorded !== undefined && recorded !== '')
|
|
115
156
|
return recorded;
|
|
116
|
-
return this.deps.settingsModel();
|
|
157
|
+
return this.deps.settingsModel(this.deps.homeDir(childSessionId));
|
|
117
158
|
}
|
|
118
159
|
/**
|
|
119
160
|
* Read the model surface. The cliDefault and the discovered identifiers come
|
|
@@ -124,8 +165,8 @@ export class CodexModelBroker {
|
|
|
124
165
|
* re-fetches on open, so it never blocks on a CLI boot).
|
|
125
166
|
*/
|
|
126
167
|
async modelInfo(childSessionId, delegationModel) {
|
|
127
|
-
const settings = this.deps.settingsModel();
|
|
128
168
|
const homeDir = this.deps.homeDir(childSessionId);
|
|
169
|
+
const settings = this.deps.settingsModel(homeDir);
|
|
129
170
|
const [cliDefault, discovered] = await Promise.all([
|
|
130
171
|
readCodexModel(homeDir).catch(() => undefined),
|
|
131
172
|
listCodexConfigModels(homeDir).catch(() => []),
|
|
@@ -144,6 +185,10 @@ export class CodexModelBroker {
|
|
|
144
185
|
: settings !== undefined ? 'settings'
|
|
145
186
|
: cliDefault !== undefined ? 'cli-config' : 'cli-builtin';
|
|
146
187
|
const inFlight = childSessionId !== undefined && this.activeDelegations().includes(childSessionId);
|
|
188
|
+
// The curated list is the whole vocabulary when non-empty — nothing from
|
|
189
|
+
// outside the endpoint's served catalog sneaks in. Empty falls back to
|
|
190
|
+
// the pre-list chain (native candidates plus configuration and history).
|
|
191
|
+
const curated = this.deps.modelList?.(homeDir) ?? [];
|
|
147
192
|
return {
|
|
148
193
|
...effective === undefined ? {} : { effective },
|
|
149
194
|
source,
|
|
@@ -151,8 +196,8 @@ export class CodexModelBroker {
|
|
|
151
196
|
...delegation === undefined ? {} : { delegation },
|
|
152
197
|
...settings === undefined ? {} : { settings },
|
|
153
198
|
...cliDefault === undefined ? {} : { cliDefault },
|
|
154
|
-
...this.deps.directory === undefined ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [settings, cliDefault, ...discovered], this.deps.recentModels()) },
|
|
155
|
-
choices: dedupeChoices([settings, cliDefault, ...discovered, ...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)), ...this.deps.recentModels()]),
|
|
199
|
+
...this.deps.directory === undefined ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [settings, cliDefault, ...discovered], curated.length > 0 ? [] : this.deps.recentModels()) },
|
|
200
|
+
choices: curated.length > 0 ? [...curated] : dedupeChoices([settings, cliDefault, ...discovered, ...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)), ...this.deps.recentModels()]),
|
|
156
201
|
live: this.deps.live(),
|
|
157
202
|
switchable: !inFlight,
|
|
158
203
|
...inFlight ? { reason: '成员有进行中的委派轮次,等其完成后再切换模型' } : {},
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/**
|
|
2
|
+
* The codex model directory: the VOCABULARY comes from the configured
|
|
3
|
+
* endpoint (`GET <baseUrl>/v1/models` with the resolved credential — the same
|
|
4
|
+
* probe the settings card's verify button runs), never from the CLI's local
|
|
5
|
+
* registry: a local list cannot prove what the endpoint serves, and showing
|
|
6
|
+
* it made a credential that does not work look valid. The native app-server
|
|
7
|
+
* `model/list` probe survives as METADATA ENRICHMENT only — it alone carries
|
|
8
|
+
* the display names, the hidden candidates, the reasoning-effort options and
|
|
9
|
+
* the `isDefault` slug (the CLI's compiled default), so it still runs
|
|
10
|
+
* CONCURRENTLY and folds into the entries whose ids the endpoint serves; its
|
|
11
|
+
* failure warns once and the endpoint data stands alone.
|
|
12
|
+
* Shared core cache owns refresh, stale data and subscriptions; this provider
|
|
13
|
+
* owns protocol and pagination (the native side).
|
|
14
|
+
*/
|
|
3
15
|
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
4
16
|
import type { LocalAgentModelDirectory } from '@khorsheed/dsh-local-agent/types';
|
|
5
|
-
|
|
17
|
+
import { type CodexEndpointAuth, type EndpointCheckFetch } from './endpoint-auth.ts';
|
|
18
|
+
/** Default overall bound for one NATIVE catalog probe (spawn → model/list answer). */
|
|
6
19
|
export declare const DEFAULT_CATALOG_PROBE_TIMEOUT_MS = 5000;
|
|
7
20
|
/** Default lifetime of a completed probe's cached answer, per scoped home. */
|
|
8
21
|
export declare const DEFAULT_CATALOG_TTL_MS: number;
|
|
@@ -12,7 +25,17 @@ export interface CodexModelCatalogDeps {
|
|
|
12
25
|
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
|
|
13
26
|
/** Diagnostic sink; a degraded probe warns once, never throws. */
|
|
14
27
|
readonly warn?: (message: string) => void;
|
|
15
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The scope's endpoint credential resolver: the endpoint probe (the
|
|
30
|
+
* directory's vocabulary) reads the credential through it, and the native
|
|
31
|
+
* probe routes its spawn at the same relay (custom provider overrides plus
|
|
32
|
+
* the key in the env), or model/list fails for a scope that has no other
|
|
33
|
+
* credential. Absent = the pre-endpoint probe shape.
|
|
34
|
+
*/
|
|
35
|
+
readonly endpoint?: (scopeHome: string) => Promise<CodexEndpointAuth>;
|
|
36
|
+
/** The HTTP fetch for the endpoint probe (injected in tests). */
|
|
37
|
+
readonly fetchImpl?: EndpointCheckFetch;
|
|
38
|
+
/** The native probe's overall bound; when set it also bounds the endpoint fetch (unset, the fetch gets 4× the native default — 20s, mirroring claude's catalog timeout). Tests inject a small value. */
|
|
16
39
|
readonly timeoutMs?: number;
|
|
17
40
|
/** Completed-probe cache lifetime; tests inject a small value. */
|
|
18
41
|
readonly ttlMs?: number;
|
|
@@ -34,11 +57,32 @@ export declare class CodexModelCatalog {
|
|
|
34
57
|
dispose(): void;
|
|
35
58
|
refresh(homeDir: string, cwd?: string): Promise<readonly string[]>;
|
|
36
59
|
/**
|
|
37
|
-
* One
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
60
|
+
* One directory load: the endpoint's served id list is the vocabulary (the
|
|
61
|
+
* card's add dialog can only offer what the endpoint answers), folded
|
|
62
|
+
* CONCURRENTLY with the native app-server probe's metadata. The endpoint
|
|
63
|
+
* answers are deliberate:
|
|
64
|
+
*
|
|
65
|
+
* - no credential resolved → empty with a "save a key first" reason;
|
|
66
|
+
* - 401/403 → empty with the rejection (definitive — cached as data);
|
|
67
|
+
* - another HTTP status → empty with it (the relay serves no model route);
|
|
68
|
+
* - a transport failure THROWS, so the cache keeps the last good list and
|
|
69
|
+
* retries on schedule instead of blanking a momentarily-down endpoint
|
|
70
|
+
* (the openproxy relay once 500'd and recovered — 2026-10-22).
|
|
71
|
+
*
|
|
72
|
+
* The native probe's failure never fails the load: endpoint data alone is
|
|
73
|
+
* complete (it warns once via the deps sink). The endpoint fetch gets a
|
|
74
|
+
* 20s ceiling by default (mirrors claude's catalog timeout); the native
|
|
75
|
+
* probe keeps its own tighter bound internally.
|
|
76
|
+
*/
|
|
77
|
+
private load;
|
|
78
|
+
/**
|
|
79
|
+
* One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
|
|
80
|
+
* spawn the app-server against the scoped home, answer the initialize
|
|
81
|
+
* handshake exactly as the live driver shapes it, ask `model/list`, then
|
|
82
|
+
* tear the process down (stdin EOF → grace → terminate). The overall
|
|
83
|
+
* timeout kills the process on expiry. Failures propagate to the caller
|
|
84
|
+
* (load demotes them to a warn).
|
|
41
85
|
*/
|
|
42
|
-
private
|
|
86
|
+
private probeNative;
|
|
43
87
|
}
|
|
44
88
|
//# sourceMappingURL=model-catalog.d.ts.map
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/**
|
|
2
|
+
* The codex model directory: the VOCABULARY comes from the configured
|
|
3
|
+
* endpoint (`GET <baseUrl>/v1/models` with the resolved credential — the same
|
|
4
|
+
* probe the settings card's verify button runs), never from the CLI's local
|
|
5
|
+
* registry: a local list cannot prove what the endpoint serves, and showing
|
|
6
|
+
* it made a credential that does not work look valid. The native app-server
|
|
7
|
+
* `model/list` probe survives as METADATA ENRICHMENT only — it alone carries
|
|
8
|
+
* the display names, the hidden candidates, the reasoning-effort options and
|
|
9
|
+
* the `isDefault` slug (the CLI's compiled default), so it still runs
|
|
10
|
+
* CONCURRENTLY and folds into the entries whose ids the endpoint serves; its
|
|
11
|
+
* failure warns once and the endpoint data stands alone.
|
|
12
|
+
* Shared core cache owns refresh, stale data and subscriptions; this provider
|
|
13
|
+
* owns protocol and pagination (the native side).
|
|
14
|
+
*/
|
|
3
15
|
import { dirname, join } from 'node:path';
|
|
4
16
|
import { StringDecoder } from 'node:string_decoder';
|
|
5
17
|
import { ModelDirectoryCache, modelDirectoryContextKey, delegationEnv } from '@khorsheed/dsh-local-agent';
|
|
6
18
|
import { DEFAULT_DISPOSE_GRACE_MS } from './codex-cli-provider.js';
|
|
7
|
-
|
|
19
|
+
import { ENDPOINT_AUTH_FILE, endpointAuthEnv, endpointConfigOverrides, fetchEndpointModels, hasEndpointCredential, OFFICIAL_BASE_URL, } from './endpoint-auth.js';
|
|
20
|
+
/** Default overall bound for one NATIVE catalog probe (spawn → model/list answer). */
|
|
8
21
|
export const DEFAULT_CATALOG_PROBE_TIMEOUT_MS = 5_000;
|
|
9
22
|
/** Default lifetime of a completed probe's cached answer, per scoped home. */
|
|
10
23
|
export const DEFAULT_CATALOG_TTL_MS = 5 * 60_000;
|
|
11
24
|
/** Grace between stdin EOF and terminate when the probe tears the process down. */
|
|
12
25
|
const PROBE_EOF_GRACE_MS = 1_000;
|
|
26
|
+
/** One directory answer that names why it is empty. */
|
|
27
|
+
function unsupported(reason) {
|
|
28
|
+
return { entries: [], complete: false, customInput: true, unsupported: true, reason };
|
|
29
|
+
}
|
|
13
30
|
/** Preserve native metadata, including hidden candidates; reject malformed pages. */
|
|
14
31
|
function catalogEntries(result) {
|
|
15
32
|
const page = result;
|
|
@@ -61,7 +78,7 @@ export class CodexModelCatalog {
|
|
|
61
78
|
this.cache = new ModelDirectoryCache({
|
|
62
79
|
load: (key, signal) => {
|
|
63
80
|
const context = JSON.parse(key);
|
|
64
|
-
return this.
|
|
81
|
+
return this.load(context.homeDir, context.cwd, signal);
|
|
65
82
|
},
|
|
66
83
|
ttlMs: deps.ttlMs ?? DEFAULT_CATALOG_TTL_MS,
|
|
67
84
|
...deps.now === undefined ? {} : { now: deps.now },
|
|
@@ -69,7 +86,9 @@ export class CodexModelCatalog {
|
|
|
69
86
|
});
|
|
70
87
|
}
|
|
71
88
|
key(homeDir, cwd = homeDir) {
|
|
72
|
-
|
|
89
|
+
// endpoint-auth.json re-keys the cache: a card credential write changes
|
|
90
|
+
// which catalog the probe would answer, so the next read re-probes.
|
|
91
|
+
const files = [join(homeDir, 'config.toml'), join(homeDir, 'auth.json'), join(homeDir, ENDPOINT_AUTH_FILE)];
|
|
73
92
|
for (let directory = cwd;; directory = dirname(directory)) {
|
|
74
93
|
files.push(join(directory, '.codex', 'config.toml'));
|
|
75
94
|
if (dirname(directory) === directory)
|
|
@@ -90,18 +109,82 @@ export class CodexModelCatalog {
|
|
|
90
109
|
return snapshot.entries.filter(entry => !entry.hidden).map(entry => entry.value);
|
|
91
110
|
}
|
|
92
111
|
/**
|
|
93
|
-
* One
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
112
|
+
* One directory load: the endpoint's served id list is the vocabulary (the
|
|
113
|
+
* card's add dialog can only offer what the endpoint answers), folded
|
|
114
|
+
* CONCURRENTLY with the native app-server probe's metadata. The endpoint
|
|
115
|
+
* answers are deliberate:
|
|
116
|
+
*
|
|
117
|
+
* - no credential resolved → empty with a "save a key first" reason;
|
|
118
|
+
* - 401/403 → empty with the rejection (definitive — cached as data);
|
|
119
|
+
* - another HTTP status → empty with it (the relay serves no model route);
|
|
120
|
+
* - a transport failure THROWS, so the cache keeps the last good list and
|
|
121
|
+
* retries on schedule instead of blanking a momentarily-down endpoint
|
|
122
|
+
* (the openproxy relay once 500'd and recovered — 2026-10-22).
|
|
123
|
+
*
|
|
124
|
+
* The native probe's failure never fails the load: endpoint data alone is
|
|
125
|
+
* complete (it warns once via the deps sink). The endpoint fetch gets a
|
|
126
|
+
* 20s ceiling by default (mirrors claude's catalog timeout); the native
|
|
127
|
+
* probe keeps its own tighter bound internally.
|
|
128
|
+
*/
|
|
129
|
+
async load(homeDir, cwd, signal) {
|
|
130
|
+
const endpoint = this.deps.endpoint === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: await this.deps.endpoint(homeDir).catch(() => undefined);
|
|
133
|
+
if (!hasEndpointCredential(endpoint)) {
|
|
134
|
+
return unsupported('No endpoint credential configured — the model list loads from the endpoint once an API Key is saved');
|
|
135
|
+
}
|
|
136
|
+
const combined = AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? DEFAULT_CATALOG_PROBE_TIMEOUT_MS * 4)]);
|
|
137
|
+
const [result, native] = await Promise.all([
|
|
138
|
+
fetchEndpointModels({ ...endpoint, baseUrl: endpoint.baseUrl ?? OFFICIAL_BASE_URL }, this.deps.fetchImpl, combined),
|
|
139
|
+
this.probeNative(homeDir, cwd, combined).catch((error) => {
|
|
140
|
+
this.deps.warn?.(`local-agent-codex: the native model/list probe failed (metadata only — the endpoint's list stands): ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
return undefined;
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
144
|
+
if (result.kind === 'unreachable') {
|
|
145
|
+
// Not a verdict: the cache keeps the last good list and retries.
|
|
146
|
+
throw new Error(`endpoint unreachable: ${result.message}`);
|
|
147
|
+
}
|
|
148
|
+
if (result.kind === 'malformed')
|
|
149
|
+
return unsupported('The endpoint answered but the model list did not parse (not /v1/models-shaped)');
|
|
150
|
+
if (result.kind === 'http') {
|
|
151
|
+
if (result.status === 401 || result.status === 403) {
|
|
152
|
+
return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
|
|
153
|
+
}
|
|
154
|
+
return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
|
|
155
|
+
}
|
|
156
|
+
if (result.models.length === 0)
|
|
157
|
+
return unsupported('The endpoint serves no models');
|
|
158
|
+
const nativeByValue = new Map((native?.entries ?? []).map(entry => [entry.value, entry]));
|
|
159
|
+
// Served ids the native registry also knows carry its metadata over
|
|
160
|
+
// (label, description, hidden, reasoning — source stays 'native', which
|
|
161
|
+
// they are: the harness's own vocabulary, just read from the endpoint in
|
|
162
|
+
// force); ids only the endpoint serves are plain.
|
|
163
|
+
const entries = result.models.map(id => nativeByValue.get(id) ?? { value: id, label: id, source: 'native' });
|
|
164
|
+
const defaultModel = native?.defaultModel;
|
|
165
|
+
return {
|
|
166
|
+
entries, complete: true, customInput: true,
|
|
167
|
+
...defaultModel === undefined ? {} : { defaultModel },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
|
|
172
|
+
* spawn the app-server against the scoped home, answer the initialize
|
|
173
|
+
* handshake exactly as the live driver shapes it, ask `model/list`, then
|
|
174
|
+
* tear the process down (stdin EOF → grace → terminate). The overall
|
|
175
|
+
* timeout kills the process on expiry. Failures propagate to the caller
|
|
176
|
+
* (load demotes them to a warn).
|
|
97
177
|
*/
|
|
98
|
-
async
|
|
178
|
+
async probeNative(homeDir, cwd, signal) {
|
|
179
|
+
const endpoint = this.deps.endpoint === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: await this.deps.endpoint(homeDir).catch(() => undefined);
|
|
99
182
|
const spec = {
|
|
100
|
-
argv: ['codex', 'app-server', '--stdio'],
|
|
183
|
+
argv: ['codex', 'app-server', ...endpointConfigOverrides(endpoint), '--stdio'],
|
|
101
184
|
cwd,
|
|
102
185
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
103
186
|
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
104
|
-
env: delegationEnv({ CODEX_HOME: homeDir }),
|
|
187
|
+
env: delegationEnv({ CODEX_HOME: homeDir, ...endpointAuthEnv(endpoint) }),
|
|
105
188
|
};
|
|
106
189
|
const child = this.deps.spawn(spec);
|
|
107
190
|
const decoder = new StringDecoder('utf8');
|
package/lib/types/provision.d.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex scoped-home provisioning:
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
* to
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* untouched; sign-out removes the credential file.
|
|
2
|
+
* Codex scoped-home provisioning: a working `config.toml` (a person may edit
|
|
3
|
+
* it — custom providers, model, effort) plus the one-way sweep of the web
|
|
4
|
+
* login's leftover `auth.json`. The harness is endpoint-key only: a stale
|
|
5
|
+
* grant sitting next to an endpoint credential would keep authenticating the
|
|
6
|
+
* built-in provider while the status reads "unauthenticated", so provisioning
|
|
7
|
+
* deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
|
|
9
8
|
* @module @khorsheed/dsh-local-agent-codex/provision
|
|
10
9
|
*/
|
|
11
10
|
/**
|
|
12
|
-
* Provision
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Provision the scoped home: ensure a `config.toml` exists (a minimal one —
|
|
12
|
+
* codex reads it for custom providers, model, and effort; the historical
|
|
13
|
+
* `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
|
|
14
|
+
* under the scoped home can never spill into the macOS keychain), and sweep
|
|
15
|
+
* the web login's leftover `auth.json` (the harness no longer consults it —
|
|
16
|
+
* endpoint key or nothing).
|
|
16
17
|
* @param homeDir - the `codex` harness's scoped home.
|
|
17
18
|
* @returns true when a config was written, false when one already existed.
|
|
18
19
|
*/
|
|
@@ -61,20 +62,4 @@ export declare function readCodexModel(homeDir: string): Promise<string | undefi
|
|
|
61
62
|
* @returns the model identifiers the file carries.
|
|
62
63
|
*/
|
|
63
64
|
export declare function listCodexConfigModels(homeDir: string): Promise<string[]>;
|
|
64
|
-
/**
|
|
65
|
-
* The credential file's modification stamp (epoch ms), undefined when absent.
|
|
66
|
-
* A completed device-code login rewrites `auth.json`, so the stamp
|
|
67
|
-
* distinguishes a fresh login from a leftover (possibly revoked) credential.
|
|
68
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
69
|
-
* @returns the marker's mtime, or undefined when no credential exists.
|
|
70
|
-
*/
|
|
71
|
-
export declare function codexCredentialStamp(homeDir: string): Promise<number | undefined>;
|
|
72
|
-
/**
|
|
73
|
-
* Sign out of the scoped account: remove the credential file, so `/<name>
|
|
74
|
-
* status` reports not authenticated and the next login authorizes a fresh
|
|
75
|
-
* account. With `cli_auth_credentials_store = "file"` the credentials live
|
|
76
|
-
* solely in `auth.json`, so removing it is the logout path.
|
|
77
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
78
|
-
*/
|
|
79
|
-
export declare function codexLogout(homeDir: string): Promise<void>;
|
|
80
65
|
//# sourceMappingURL=provision.d.ts.map
|
package/lib/types/provision.js
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex scoped-home provisioning:
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
* to
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* untouched; sign-out removes the credential file.
|
|
2
|
+
* Codex scoped-home provisioning: a working `config.toml` (a person may edit
|
|
3
|
+
* it — custom providers, model, effort) plus the one-way sweep of the web
|
|
4
|
+
* login's leftover `auth.json`. The harness is endpoint-key only: a stale
|
|
5
|
+
* grant sitting next to an endpoint credential would keep authenticating the
|
|
6
|
+
* built-in provider while the status reads "unauthenticated", so provisioning
|
|
7
|
+
* deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
|
|
9
8
|
* @module @khorsheed/dsh-local-agent-codex/provision
|
|
10
9
|
*/
|
|
11
|
-
import { readFile, rm,
|
|
10
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
11
|
import { join } from 'node:path';
|
|
13
|
-
/**
|
|
14
|
-
const AUTH_FILE = 'auth.json';
|
|
15
|
-
/** Minimal scoped config: keep credentials in the scoped home. */
|
|
12
|
+
/** Minimal scoped config: keep any hand-run login's credentials in the scoped home. */
|
|
16
13
|
const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
|
|
17
14
|
# credentials inside the scoped home (auth.json) instead of the OS keychain.
|
|
18
15
|
cli_auth_credentials_store = "file"
|
|
19
16
|
`;
|
|
17
|
+
/** The web login's credential file, swept on every boot (endpoint-key only now). */
|
|
18
|
+
const LEGACY_AUTH_FILE = 'auth.json';
|
|
20
19
|
/**
|
|
21
|
-
* Provision
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* Provision the scoped home: ensure a `config.toml` exists (a minimal one —
|
|
21
|
+
* codex reads it for custom providers, model, and effort; the historical
|
|
22
|
+
* `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
|
|
23
|
+
* under the scoped home can never spill into the macOS keychain), and sweep
|
|
24
|
+
* the web login's leftover `auth.json` (the harness no longer consults it —
|
|
25
|
+
* endpoint key or nothing).
|
|
25
26
|
* @param homeDir - the `codex` harness's scoped home.
|
|
26
27
|
* @returns true when a config was written, false when one already existed.
|
|
27
28
|
*/
|
|
28
29
|
export async function provisionCodexConfig(homeDir) {
|
|
30
|
+
// The sweep runs even when the config already exists: a leftover grant from
|
|
31
|
+
// the web-login era must not keep working past the upgrade.
|
|
32
|
+
await rm(join(homeDir, LEGACY_AUTH_FILE), { force: true });
|
|
29
33
|
try {
|
|
30
34
|
await readFile(join(homeDir, 'config.toml'));
|
|
31
35
|
return false;
|
|
@@ -186,28 +190,3 @@ export async function listCodexConfigModels(homeDir) {
|
|
|
186
190
|
}
|
|
187
191
|
return found;
|
|
188
192
|
}
|
|
189
|
-
/**
|
|
190
|
-
* The credential file's modification stamp (epoch ms), undefined when absent.
|
|
191
|
-
* A completed device-code login rewrites `auth.json`, so the stamp
|
|
192
|
-
* distinguishes a fresh login from a leftover (possibly revoked) credential.
|
|
193
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
194
|
-
* @returns the marker's mtime, or undefined when no credential exists.
|
|
195
|
-
*/
|
|
196
|
-
export async function codexCredentialStamp(homeDir) {
|
|
197
|
-
try {
|
|
198
|
-
return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
return undefined;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Sign out of the scoped account: remove the credential file, so `/<name>
|
|
206
|
-
* status` reports not authenticated and the next login authorizes a fresh
|
|
207
|
-
* account. With `cli_auth_credentials_store = "file"` the credentials live
|
|
208
|
-
* solely in `auth.json`, so removing it is the logout path.
|
|
209
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
210
|
-
*/
|
|
211
|
-
export async function codexLogout(homeDir) {
|
|
212
|
-
await rm(join(homeDir, AUTH_FILE), { force: true });
|
|
213
|
-
}
|
package/lib/types/records.d.ts
CHANGED
|
@@ -13,13 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
15
15
|
import type { LocalAgentSessionRecord } from '@khorsheed/dsh-local-agent';
|
|
16
|
-
/**
|
|
17
|
-
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
18
|
-
* file exists. Absent means the device-code flow has not completed.
|
|
19
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
20
|
-
* @returns true when credentials are present.
|
|
21
|
-
*/
|
|
22
|
-
export declare function codexAuthenticated(homeDir: string): Promise<boolean>;
|
|
23
16
|
/**
|
|
24
17
|
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
25
18
|
* malformed values yield an undefined record rather than failing the list.
|
|
@@ -42,10 +35,12 @@ export declare function listCodexSessions(homeDir: string): Promise<readonly Loc
|
|
|
42
35
|
* `input_tokens` is the TOTAL input including cache hits (OpenAI-style
|
|
43
36
|
* accounting, confirmed against `total_tokens` in the rollout token_count),
|
|
44
37
|
* and `cached_input_tokens` is the cache-read subset — so the uncached
|
|
45
|
-
* bucket subtracts the cached portion to avoid double counting.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
38
|
+
* bucket subtracts the cached portion to avoid double counting.
|
|
39
|
+
* `cached_input_tokens` rides every payload (zero included), so
|
|
40
|
+
* `cacheReadTokens` is written whenever the field is present — a reader can
|
|
41
|
+
* tell "no hits" from "not reported". Codex also reports cache creation
|
|
42
|
+
* (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
|
|
43
|
+
* four-bucket contract: uncached input, output, cache read, cache write).
|
|
49
44
|
* @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
|
|
50
45
|
* rollout `token_count` `info.last_token_usage`/`total_token_usage`).
|
|
51
46
|
* @returns the shared usage record.
|
package/lib/types/records.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* contents are always untrusted input.
|
|
12
12
|
* @module @khorsheed/dsh-local-agent-codex/records
|
|
13
13
|
*/
|
|
14
|
-
import { open,
|
|
14
|
+
import { open, readdir } from 'node:fs/promises';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
16
|
/** Rollout files live under this directory inside the scoped home. */
|
|
17
17
|
const SESSIONS_ROOT = 'sessions';
|
|
@@ -28,24 +28,6 @@ const FULL_BYTES = 8 * 1024 * 1024;
|
|
|
28
28
|
const ROLLOUT_WINDOW_SLACK_MS = 5 * 60_000;
|
|
29
29
|
/** Extra future slack for the window end (file timestamps may skew ahead). */
|
|
30
30
|
const ROLLOUT_WINDOW_FUTURE_MS = 60_000;
|
|
31
|
-
/**
|
|
32
|
-
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
33
|
-
* file exists. Absent means the device-code flow has not completed.
|
|
34
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
35
|
-
* @returns true when credentials are present.
|
|
36
|
-
*/
|
|
37
|
-
export async function codexAuthenticated(homeDir) {
|
|
38
|
-
try {
|
|
39
|
-
await readFile(join(homeDir, 'auth.json'));
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
// No auth file yet: not authenticated.
|
|
44
|
-
if (error.code === 'ENOENT')
|
|
45
|
-
return false;
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
31
|
/**
|
|
50
32
|
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
51
33
|
* malformed values yield an undefined record rather than failing the list.
|
|
@@ -167,10 +149,12 @@ export async function listCodexSessions(homeDir) {
|
|
|
167
149
|
* `input_tokens` is the TOTAL input including cache hits (OpenAI-style
|
|
168
150
|
* accounting, confirmed against `total_tokens` in the rollout token_count),
|
|
169
151
|
* and `cached_input_tokens` is the cache-read subset — so the uncached
|
|
170
|
-
* bucket subtracts the cached portion to avoid double counting.
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
152
|
+
* bucket subtracts the cached portion to avoid double counting.
|
|
153
|
+
* `cached_input_tokens` rides every payload (zero included), so
|
|
154
|
+
* `cacheReadTokens` is written whenever the field is present — a reader can
|
|
155
|
+
* tell "no hits" from "not reported". Codex also reports cache creation
|
|
156
|
+
* (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
|
|
157
|
+
* four-bucket contract: uncached input, output, cache read, cache write).
|
|
174
158
|
* @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
|
|
175
159
|
* rollout `token_count` `info.last_token_usage`/`total_token_usage`).
|
|
176
160
|
* @returns the shared usage record.
|
|
@@ -179,9 +163,12 @@ export function usageFromCodex(usage) {
|
|
|
179
163
|
const raw = usage;
|
|
180
164
|
const input = Number(raw.input_tokens);
|
|
181
165
|
const cached = Number(raw.cached_input_tokens);
|
|
166
|
+
const cacheWrite = Number(raw.cache_write_input_tokens);
|
|
182
167
|
const output = Number(raw.output_tokens);
|
|
168
|
+
// `input_tokens` already contains both cache buckets (total = input +
|
|
169
|
+
// output); the shared contract keeps the three input buckets disjoint.
|
|
183
170
|
const uncached = Number.isFinite(input) && Number.isFinite(cached)
|
|
184
|
-
? Math.max(0, input - cached)
|
|
171
|
+
? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0))
|
|
185
172
|
: Number.isFinite(input)
|
|
186
173
|
? input
|
|
187
174
|
: 0;
|
|
@@ -189,8 +176,10 @@ export function usageFromCodex(usage) {
|
|
|
189
176
|
inputTokens: uncached,
|
|
190
177
|
outputTokens: Number.isFinite(output) ? output : 0,
|
|
191
178
|
};
|
|
192
|
-
if (Number.isFinite(cached)
|
|
179
|
+
if (Number.isFinite(cached))
|
|
193
180
|
usageRecord.cacheReadTokens = cached;
|
|
181
|
+
if (Number.isFinite(cacheWrite) && cacheWrite > 0)
|
|
182
|
+
usageRecord.cacheWriteTokens = cacheWrite;
|
|
194
183
|
return usageRecord;
|
|
195
184
|
}
|
|
196
185
|
/**
|
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED