@coda-console/codex 1.7.0 → 1.8.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.
Files changed (2) hide show
  1. package/agent/providers.mjs +68 -36
  2. package/package.json +9 -5
@@ -1,64 +1,91 @@
1
- // The model service chosen in Coda (`codex.modelProvider`, Coda's docs/plugins.md「模型服务」): another plugin's service that
2
- // speaks the OpenAI Responses API (Codex no longer speaks Chat Completions), from CODA_MODEL_PROVIDERS. The models its address
3
- // lists (`<地址>/models`) join Codex's own, each named by the service and the model. Codex binds a thread to one model
4
- // provider, so a turn on another provider's model first moves the session there the way codex-acp moves it for ACP's
5
- // `providers/set` and `providers/disable`: its app-server starts again with the service as the provider of its OpenAI slot
6
- // (or without it), and the session's thread is resumed on it. The machine's Codex configuration is not touched.
1
+ // The model services chosen in Coda (`codex.modelProviders`, Coda's docs/plugins.md「模型服务」): other plugins' services
2
+ // that speak the OpenAI Responses API (Codex no longer speaks Chat Completions), from CODA_MODEL_PROVIDERS. The models each
3
+ // lists (`<地址>/models`) join Codex's own, a group of the service's each (Coda's docs/agent-protocol.md「模型的分组」). Codex
4
+ // binds a thread to one model provider, so a turn on another provider's model first moves the session there the way
5
+ // codex-acp moves it for ACP's `providers/set` and `providers/disable`: its app-server starts again with that service as the
6
+ // provider of its OpenAI slot (or without one), and the session's thread is resumed on it. The machine's Codex
7
+ // configuration is not touched.
7
8
  import { existsSync, readFileSync } from 'node:fs';
8
9
  import { carriedInstructions } from './instructions.mjs';
9
10
 
10
- export const SETTING = 'codex.modelProvider';
11
+ export const SETTING = 'codex.modelProviders';
11
12
  const log = text => process.stderr.write(`[codex-agent] ${text}\n`);
12
13
  const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
13
14
  const LIST_TIMEOUT = 8000;
14
15
 
15
- // The chosen service: its address, title and key (read from the file Coda keeps it in). Null when none is chosen, or the
16
- // one chosen is not on this machine or speaks another API.
17
- export function chosenService(env = process.env) {
18
- const ref = parse(env.CODA_PLUGIN_SETTINGS)[SETTING];
19
- if (!ref) return null;
20
- const provided = parse(env.CODA_MODEL_PROVIDERS)[ref];
21
- if (!provided?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return null; }
22
- if (provided.api !== 'openai-responses') { log(`模型服务 ${ref} 讲 ${provided.api},Codex 只接 openai-responses 的`); return null; }
23
- let key;
24
- try { key = provided.keyFile ? readFileSync(provided.keyFile, 'utf8').trim() || undefined : undefined; }
25
- catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
26
- return { ref, title: provided.title || ref, url: provided.url, key };
16
+ // The services chosen, in the order chosen: each its address, title and key (read from the file Coda keeps it in). One not
17
+ // on this machine, or speaking another API, is left out.
18
+ export function chosenServices(env = process.env) {
19
+ const refs = parse(env.CODA_PLUGIN_SETTINGS)[SETTING], provided = parse(env.CODA_MODEL_PROVIDERS);
20
+ return (Array.isArray(refs) ? refs : []).flatMap(ref => {
21
+ const service = provided[ref];
22
+ if (!service?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return []; }
23
+ if (service.api !== 'openai-responses') { log(`模型服务 ${ref} 讲 ${service.api},Codex 只接 openai-responses 的`); return []; }
24
+ let key;
25
+ try { key = service.keyFile ? readFileSync(service.keyFile, 'utf8').trim() || undefined : undefined; }
26
+ catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
27
+ return [{ ref, title: service.title || ref, url: service.url, key }];
28
+ });
27
29
  }
28
30
 
29
- // The models the service lists, as Codex's model list has them (app-server `model/list`): `<服务>/<模型>`, named by the
30
- // model and the service, with no effort or speed to choose.
31
+ // The models a service lists, as Codex's model list has them (app-server `model/list`): `<服务>/<模型>`, named by the
32
+ // model, with no effort or speed to choose; `codaService` and `codaGroup` say whose they are, for the session's move and
33
+ // the model's group.
31
34
  export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
32
35
  const at = `${url.replace(/\/+$/, '')}/models`;
33
36
  const response = await fetcher(at, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
34
37
  if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
35
38
  const body = await response.json();
36
39
  return (Array.isArray(body?.data) ? body.data : []).filter(model => typeof model?.id === 'string' && model.id).map(model => ({
37
- id: `${ref}/${model.id}`, model: model.id, displayName: `${model.id} (${title})`, description: '', hidden: false, isDefault: false,
38
- supportedReasoningEfforts: [], defaultReasoningEffort: 'medium', inputModalities: ['text', 'image'], serviceTiers: [] }));
40
+ id: `${ref}/${model.id}`, model: model.id, displayName: model.id, description: '', hidden: false, isDefault: false,
41
+ supportedReasoningEfforts: [], defaultReasoningEffort: 'medium', inputModalities: ['text', 'image'], serviceTiers: [],
42
+ codaService: ref, codaGroup: title }));
39
43
  }
40
44
 
41
- // One service a process (Coda runs one session a process), listed once, as its session opens.
45
+ // The services a process has (Coda runs one session a process), listed once, as its session opens.
42
46
  export class ServiceModels {
43
- // `on`: the service's address the session runs on, null on Codex's own (where it opens), undefined when a move to one or
44
- // the other failed partway: codex-acp sets its gateway before the thread is resumed on it, so that is no sign of either,
45
- // and keeps its old client, closed, when the app-server it started again does not start.
46
- constructor(service = chosenService(), fetcher = fetch) { Object.assign(this, { service, fetcher }); this.models = []; this.on = null; }
47
+ // `on`: the service the session runs on, null on Codex's own (where it opens), undefined when a move to one or another
48
+ // failed partway: codex-acp sets its gateway before the thread is resumed on it, so that is no sign of either, and keeps
49
+ // its old client, closed, when the app-server it started again does not start.
50
+ constructor(services = chosenServices(), fetcher = fetch) { Object.assign(this, { services, fetcher }); this.models = []; this.on = null; }
51
+ // Theirs in the order the services are chosen: none of one that cannot list them now.
47
52
  list() {
48
- return this.listed ??= (!this.service ? Promise.resolve([]) : serviceModels(this.service, this.fetcher)
49
- .catch(e => { log(`模型服务 ${this.service.ref} 列不出模型:${e.message}`); return []; })).then(models => (this.models = models));
53
+ return this.listed ??= Promise.all(this.services.map(service => serviceModels(service, this.fetcher)
54
+ .catch(e => { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }))).then(lists => (this.models = lists.flat()));
50
55
  }
51
56
  served(model) { return this.models.find(m => m.id === model) ?? null; }
57
+ // The model select's options (codex-acp's, of Codex's models and the services'), grouped by where they come from once a
58
+ // service's are among them: Codex's own, then each service's under its title. Codex's alone are one plain list.
59
+ grouped(options) {
60
+ if (!options.some(option => this.served(option.value))) return options;
61
+ const groups = [{ group: 'codex', name: 'Codex', options: [] }];
62
+ for (const option of options) {
63
+ const served = this.served(option.value), id = served?.codaService ?? 'codex';
64
+ let group = groups.find(g => g.group === id);
65
+ if (!group) groups.push(group = { group: id, name: served.codaGroup, options: [] });
66
+ group.options.push(option);
67
+ }
68
+ return groups.filter(g => g.options.length);
69
+ }
52
70
  // What a turn names to Codex: the service's own name for one of its models.
53
71
  wire(model) { return this.served(model)?.model ?? model; }
72
+ // A stop that comes while a turn waits on the move has no turn of codex-acp's to interrupt yet: it is the waiting turn's,
73
+ // which ends cancelled with its input not given (`route` says so).
74
+ cancel(sessionId) { if (this.moving === sessionId) this.stopped = true; }
75
+ async route(server, params) {
76
+ this.moving = params.sessionId; this.stopped = false;
77
+ try { await this.move(server, params); } finally { this.moving = null; }
78
+ return this.stopped;
79
+ }
54
80
  // Before a turn of codex-acp's `server`: the session on the provider of its model, its thread resumed with Coda's
55
81
  // instructions as it was opened. A thread nothing was said in yet is not written down, and would not be resumed after the
56
82
  // restart: naming it, by what it is first told, writes it. A move that failed is made again from the restart, on which
57
83
  // codex-acp has a client again; the thread was written down before it.
58
- async route(server, { sessionId, prompt }) {
84
+ async move(server, { sessionId, prompt }) {
59
85
  const state = server.sessions.get(sessionId);
60
86
  if (!state) return;
61
- const served = this.served(state.currentModelId.replace(/\[[^\]]*\]$/, '')), wanted = served ? this.service.url : null;
87
+ const served = this.served(state.currentModelId.replace(/\[[^\]]*\]$/, ''));
88
+ const service = served ? this.services.find(s => s.ref === served.codaService) : null, wanted = service?.ref ?? null;
62
89
  if (this.on === wanted) return;
63
90
  if (this.on !== undefined) {
64
91
  const client = server.codexAcpClient.appServerClient;
@@ -66,8 +93,8 @@ export class ServiceModels {
66
93
  if (thread.path && !existsSync(thread.path)) await client.threadSetName({ threadId: sessionId, name: firstWords(prompt) });
67
94
  }
68
95
  this.on = undefined;
69
- await carriedInstructions(() => served ? server.setProvider({ providerId: 'openai', apiType: 'openai', baseUrl: this.service.url,
70
- headers: this.service.key ? { Authorization: `Bearer ${this.service.key}` } : {} }) : server.disableProvider({ providerId: 'openai' }));
96
+ await carriedInstructions(() => service ? server.setProvider({ providerId: 'openai', apiType: 'openai', baseUrl: service.url,
97
+ headers: service.key ? { Authorization: `Bearer ${service.key}` } : {} }) : server.disableProvider({ providerId: 'openai' }));
71
98
  this.on = wanted;
72
99
  }
73
100
  // Codex knows nothing of the service's models: that it runs one on fallback metadata, or resumes a session last on one
@@ -81,11 +108,16 @@ const firstWords = prompt => Array.from((prompt ?? []).filter(b => b.type === 't
81
108
 
82
109
  export const services = new ServiceModels();
83
110
  const patches = [
84
- // Codex's models, then the service's.
111
+ // Codex's models, then the services'.
85
112
  [' } while (cursor);\n return models;\n }', ' } while (cursor);\n return [...models, ...await codaServices.list()];\n }', 1],
113
+ // Listed in groups once a service's are among them.
114
+ [' currentValue: currentBaseModelId,\n options,\n', ' currentValue: currentBaseModelId,\n options: codaServices.grouped(options),\n', 1],
86
115
  // A turn first moves the session to the provider of its model, and names the model as that provider does.
87
116
  [' async prompt(params, signal, onTurnStarted) {\n if (this.providerUpdate !== null) {',
88
- ' async prompt(params, signal, onTurnStarted) {\n await codaServices.route(this, params);\n if (this.providerUpdate !== null) {', 1],
117
+ ' async prompt(params, signal, onTurnStarted) {\n if (await codaServices.route(this, params)) return { stopReason: "cancelled" };\n if (this.providerUpdate !== null) {', 1],
118
+ // A stop while the session moves is the moving turn's.
119
+ [' async cancel(params) {\n const sessionState = this.sessions.get(params.sessionId);',
120
+ ' async cancel(params) {\n codaServices.cancel(params.sessionId);\n const sessionState = this.sessions.get(params.sessionId);', 1],
89
121
  [' model: modelId.model,\n serviceTier\n }, onTurnStarted);', ' model: codaServices.wire(modelId.model),\n serviceTier\n }, onTurnStarted);', 1],
90
122
  [' case "warning":\n return this.createWarningEvent(notification.params);',
91
123
  ' case "warning":\n if (codaServices.quiet(notification.params)) return null;\n return this.createWarningEvent(notification.params);', 1],
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "id": "codex",
4
4
  "displayName": "Codex",
5
5
  "description": "Codex Agent:Coda 自带的入口运行 codex-acp,报告子 Agent 与后台命令。额度与登录由账号来源插件接管;未接入账号来源时使用 Codex 自己的登录面板。",
6
- "version": "1.7.0",
6
+ "version": "1.8.0",
7
7
  "dependencies": {
8
8
  "@agentclientprotocol/codex-acp": "1.12.0",
9
9
  "@openai/codex": "0.162.1"
@@ -40,12 +40,16 @@
40
40
  "configuration": {
41
41
  "title": "Codex",
42
42
  "properties": {
43
- "codex.modelProvider": {
44
- "type": "string",
45
- "format": "coda-model-provider",
43
+ "codex.modelProviders": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "string",
47
+ "format": "coda-model-provider",
48
+ "modelApis": ["openai-responses"]
49
+ },
46
50
  "title": "模型服务",
47
51
  "order": 1,
48
- "description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Codex 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Codex 自己的模型照旧。只接 OpenAI Responses 接口(openai-responses)的服务:Codex 不再支持 Chat Completions。"
52
+ "description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Codex 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Codex 自己的模型照旧。只列 OpenAI Responses 接口(openai-responses)的服务:Codex 不再支持 Chat Completions。"
49
53
  }
50
54
  }
51
55
  },