@coda-console/claude 1.4.1 → 1.5.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/agent/models.mjs CHANGED
@@ -29,13 +29,26 @@ export function reportedModel(models, current, model) {
29
29
 
30
30
  const select = (id, name, category, currentValue, choices) => ({ id, name, category, type: 'select', currentValue,
31
31
  options: choices.map(c => ({ value: c.id, name: c.name, ...(c._meta ? { _meta: c._meta } : {}) })) });
32
+ // The models, grouped by where they come from once a model service's are among them (ACP's select groups, Coda's
33
+ // docs/agent-protocol.md「模型的分组」): Claude's own, then each service's, under its title. Claude's alone are one plain list.
34
+ function modelSelect(currentValue, models) {
35
+ if (!models.some(model => model.service)) return select('model', '模型', 'model', currentValue, models);
36
+ const groups = [{ group: 'claude', name: 'Claude', options: [] }];
37
+ for (const model of models) {
38
+ const id = model.service ?? 'claude';
39
+ let group = groups.find(g => g.group === id);
40
+ if (!group) groups.push(group = { group: id, name: model.group || id, options: [] });
41
+ group.options.push({ value: model.id, name: model.name });
42
+ }
43
+ return { id: 'model', name: '模型', category: 'model', type: 'select', currentValue, options: groups.filter(g => g.options.length) };
44
+ }
32
45
  // The effort that leaves it to the model, marked as the agent's own default (docs/agent-protocol.md): what Coda goes back to
33
46
  // when the session's effort is taken off.
34
47
  export const MODEL_EFFORT = 'default';
35
48
  // `current`: { mode, model, effort }. An effort not chosen is null: the model's own default.
36
49
  export function configOptions(models, current) {
37
50
  const efforts = models.find(m => m.id === current.model)?.efforts ?? [];
38
- return [select('mode', '审批模式', 'mode', current.mode, MODES), select('model', '模型', 'model', current.model, models),
51
+ return [select('mode', '审批模式', 'mode', current.mode, MODES), modelSelect(current.model, models),
39
52
  ...(efforts.length ? [select('effort', '推理强度', 'thought_level', efforts.includes(current.effort) ? current.effort : MODEL_EFFORT,
40
53
  [{ id: MODEL_EFFORT, name: '模型默认', _meta: { coda: { default: true } } }, ...efforts.map(id => ({ id, name: id }))])] : [])];
41
54
  }
@@ -1,38 +1,39 @@
1
- // The model service chosen in Coda (`claude.modelProvider`, Coda's docs/plugins.md「模型服务」): another plugin's service that
2
- // speaks Anthropic Messages, from CODA_MODEL_PROVIDERS. The models its address lists (`<地址>/models`) join Claude's own, each
3
- // named by the service and the model; a session on one runs Claude Code with the service as its API (`serviceSettings`), set
4
- // for that session's process only.
1
+ // The model services chosen in Coda (`claude.modelProviders`, Coda's docs/plugins.md「模型服务」): other plugins' services
2
+ // that speak Anthropic Messages, from CODA_MODEL_PROVIDERS. The models each lists (`<地址>/models`) join Claude's own, a group
3
+ // of the service's each (Coda's docs/agent-protocol.md「模型的分组」); a session on one runs Claude Code with its service as
4
+ // its API (`serviceSettings`), set for that session's process only.
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { authOverrides } from './auth.mjs';
7
7
 
8
- export const SETTING = 'claude.modelProvider';
8
+ export const SETTING = 'claude.modelProviders';
9
9
  const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
10
10
  const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
11
11
  const LIST_TIMEOUT = 8000;
12
12
 
13
- // The chosen service: its address, title and key (read from the file Coda keeps it in). Null when none is chosen, or the
14
- // one chosen is not on this machine or speaks another API.
15
- export function chosenService(env = process.env) {
16
- const ref = parse(env.CODA_PLUGIN_SETTINGS)[SETTING];
17
- if (!ref) return null;
18
- const provided = parse(env.CODA_MODEL_PROVIDERS)[ref];
19
- if (!provided?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return null; }
20
- if (provided.api !== 'anthropic-messages') { log(`模型服务 ${ref} 讲 ${provided.api},Claude 只接 anthropic-messages 的`); return null; }
21
- let key;
22
- try { key = provided.keyFile ? readFileSync(provided.keyFile, 'utf8').trim() || undefined : undefined; }
23
- catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
24
- return { ref, title: provided.title || ref, url: provided.url, key };
13
+ // The services chosen, in the order chosen: each its address, title and key (read from the file Coda keeps it in). One not
14
+ // on this machine, or speaking another API, is left out.
15
+ export function chosenServices(env = process.env) {
16
+ const refs = parse(env.CODA_PLUGIN_SETTINGS)[SETTING], provided = parse(env.CODA_MODEL_PROVIDERS);
17
+ return (Array.isArray(refs) ? refs : []).flatMap(ref => {
18
+ const service = provided[ref];
19
+ if (!service?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return []; }
20
+ if (service.api !== 'anthropic-messages') { log(`模型服务 ${ref} 讲 ${service.api},Claude 只接 anthropic-messages 的`); return []; }
21
+ let key;
22
+ try { key = service.keyFile ? readFileSync(service.keyFile, 'utf8').trim() || undefined : undefined; }
23
+ catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
24
+ return [{ ref, title: service.title || ref, url: service.url, key }];
25
+ });
25
26
  }
26
27
 
27
- // The models the service lists, asked as an Anthropic client asks (Coda's「检查可用模型」): `<服务>/<模型>`, named by the
28
- // model and the service. They have no effort to choose.
28
+ // The models a service lists, asked as an Anthropic client asks (Coda's「检查可用模型」): `<服务>/<模型>`, named by the
29
+ // model, in the service's group. They have no effort to choose.
29
30
  export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
30
31
  const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
31
32
  const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
32
33
  if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
33
34
  const body = await response.json();
34
35
  return (Array.isArray(body?.data) ? body.data : []).filter(model => typeof model?.id === 'string' && model.id)
35
- .map(model => ({ id: `${ref}/${model.id}`, name: `${model.id} (${title})`, efforts: [], model: model.id }));
36
+ .map(model => ({ id: `${ref}/${model.id}`, name: model.id, efforts: [], model: model.id, service: ref, group: title }));
36
37
  }
37
38
 
38
39
  // The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
@@ -45,9 +46,10 @@ export function serviceSettings({ url, key }, model) {
45
46
  CLAUDE_CODE_SUBAGENT_MODEL: model } };
46
47
  }
47
48
 
48
- // The service's models, listed with Claude's own: none when it cannot list them now.
49
- export async function listedModels(service) {
50
- if (!service) return [];
51
- try { return await serviceModels(service); }
52
- catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
49
+ // The services' models, listed with Claude's own in the order the services are chosen: none of one that cannot list them now.
50
+ export async function listedModels(services) {
51
+ return (await Promise.all(services.map(async service => {
52
+ try { return await serviceModels(service); }
53
+ catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
54
+ }))).flat();
53
55
  }
package/agent/session.mjs CHANGED
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
12
12
  import { ClaudeSessionLimits, accountHash } from './limits.mjs';
13
13
  import { sdkEnvironment } from './auth.mjs';
14
- import { chosenService, listedModels, serviceSettings } from './providers.mjs';
14
+ import { chosenServices, listedModels, serviceSettings } from './providers.mjs';
15
15
  import { SessionTitle } from './titles.mjs';
16
16
  import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
17
17
  import { permissionRequest } from './permissions.mjs';
@@ -59,8 +59,8 @@ export class ClaudeSession {
59
59
  constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
60
60
  Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
61
61
  this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
62
- // The model service chosen in Coda (providers.mjs), and the model of it the query runs on: null on Claude's own.
63
- this.service = chosenService(env); this.serviceModel = null;
62
+ // The model services chosen in Coda (providers.mjs), and the service's model the query runs on: null on Claude's own.
63
+ this.services = chosenServices(env); this.serviceModel = null;
64
64
  this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
65
65
  // Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
66
66
  this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
@@ -122,7 +122,7 @@ export class ClaudeSession {
122
122
  // Coda's servers are stdio ones; the SDK keys them by name.
123
123
  const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
124
124
  [name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
125
- // What every query of the session starts with; a model of the chosen service runs in a query of its own (`reroute`).
125
+ // What every query of the session starts with; a model of a chosen service runs in a query of its own (`reroute`).
126
126
  this.launch = {
127
127
  // Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
128
128
  permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
@@ -146,7 +146,7 @@ export class ClaudeSession {
146
146
  onElicitation: (request, context) => this.elicitation(request, context),
147
147
  stderr: data => process.stderr.write(data),
148
148
  };
149
- const listed = listedModels(this.service);
149
+ const listed = listedModels(this.services);
150
150
  this.start(resume ? { resume } : { sessionId: this.sessionId });
151
151
  const initialization = await this.initialized(), served = await listed;
152
152
  if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
@@ -164,14 +164,14 @@ export class ClaudeSession {
164
164
  throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
165
165
  }
166
166
  }
167
- // A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of the chosen
167
+ // A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of a chosen
168
168
  // service, which it runs with that service as its API and that model for every role. One started after the session
169
169
  // opened goes on with the model, mode and effort chosen.
170
170
  start(at, model) {
171
171
  this.input = new InputStream();
172
172
  const entry = this.served(model);
173
173
  const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
174
- ...(entry ? serviceSettings(this.service, entry.model) : {}) };
174
+ ...(entry ? serviceSettings(this.services.find(service => service.ref === entry.service), entry.model) : {}) };
175
175
  this.serviceModel = entry ? model : null;
176
176
  this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
177
177
  ...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
@@ -184,7 +184,7 @@ export class ClaudeSession {
184
184
  new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
185
185
  .finally(() => clearTimeout(timer));
186
186
  }
187
- // The model chosen is of another service than the query's (Claude's own, or the chosen service's): the session goes on in a
187
+ // The model chosen is of another service than the query's (Claude's own, or a chosen service's): the session goes on in a
188
188
  // query started for it, resuming what the last one said. Before a turn, as Coda sets a session's model before each.
189
189
  async reroute() {
190
190
  const previous = this.query;
@@ -203,9 +203,9 @@ export class ClaudeSession {
203
203
  throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
204
204
  }
205
205
  }
206
- // The chosen service's model that `model` is; null for one of Claude's own.
206
+ // The chosen services' model that `model` is; null for one of Claude's own.
207
207
  served(model) { return this.models.find(m => m.id === model && m.model) ?? null; }
208
- // Whether the next turn runs in another query than this one: on the service's models, or back on Claude's own.
208
+ // Whether the next turn runs in another query than this one: on another service's model, or back on Claude's own.
209
209
  rerouted() { return (this.served(this.current.model)?.id ?? null) !== this.serviceModel; }
210
210
  async configure(configId, value) {
211
211
  if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
@@ -214,7 +214,7 @@ export class ClaudeSession {
214
214
  await this.query.setPermissionMode(value); this.current.mode = value;
215
215
  } else if (configId === 'model') {
216
216
  if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
217
- // Between Claude's own models the query switches; to or from the service's, the next turn runs in another one, and
217
+ // Between Claude's own models the query switches; to or from a service's, the next turn runs in another one, and
218
218
  // what this one still runs would end with it.
219
219
  const switching = (this.served(value)?.id ?? null) !== this.serviceModel;
220
220
  if (switching && (this.prompts.size || this.waits.pending)) throw failure('后台任务、子 Agent 或定时唤醒还没结束,结束后才能换到这个模型', -32602);
@@ -321,7 +321,7 @@ export class ClaudeSession {
321
321
  if (m.session_id !== this.sessionId) return;
322
322
  this.terminal = m.terminal_slash_commands ?? [];
323
323
  // Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
324
- // service's model reports it by the service's name for it.
324
+ // service's model reports it by that service's name for it.
325
325
  this.current = { ...this.current, model: this.serviceModel ?? reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
326
326
  if (!this.title) {
327
327
  const sessionId = this.sessionId;
package/package.json CHANGED
@@ -3,7 +3,8 @@
3
3
  "id": "claude",
4
4
  "displayName": "Claude",
5
5
  "description": "Claude SDK Agent:Coda 基于 Claude Agent SDK 写的 ACP 入口,一个进程一个 SDK 会话;资源页的「Claude」面板显示会话报告的额度,可从 Coda 登录。",
6
- "version": "1.4.1",
6
+ "version": "1.5.1",
7
+ "categories": ["Agent"],
7
8
  "dependencies": {
8
9
  "@agentclientprotocol/sdk": "1.4.0",
9
10
  "@anthropic-ai/claude-agent-sdk": "0.3.296"
@@ -35,12 +36,16 @@
35
36
  "configuration": {
36
37
  "title": "Claude",
37
38
  "properties": {
38
- "claude.modelProvider": {
39
- "type": "string",
40
- "format": "coda-model-provider",
39
+ "claude.modelProviders": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "string",
43
+ "format": "coda-model-provider",
44
+ "modelApis": ["anthropic-messages"]
45
+ },
41
46
  "title": "模型服务",
42
47
  "order": 1,
43
- "description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Claude 自己的模型照旧。只接 Anthropic 接口(anthropic-messages)的服务。"
48
+ "description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Claude 自己的模型照旧。只列 Anthropic 接口(anthropic-messages)的服务。"
44
49
  }
45
50
  }
46
51
  },