@coda-console/claude 1.4.1 → 1.5.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/agent/models.mjs +14 -1
- package/agent/providers.mjs +27 -25
- package/agent/session.mjs +12 -12
- package/package.json +9 -5
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),
|
|
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
|
}
|
package/agent/providers.mjs
CHANGED
|
@@ -1,38 +1,39 @@
|
|
|
1
|
-
// The model
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
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
|
|
14
|
-
//
|
|
15
|
-
export function
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
// model
|
|
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:
|
|
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
|
|
49
|
-
export async function listedModels(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
|
63
|
-
this.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,7 @@
|
|
|
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.
|
|
6
|
+
"version": "1.5.0",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
9
9
|
"@anthropic-ai/claude-agent-sdk": "0.3.296"
|
|
@@ -35,12 +35,16 @@
|
|
|
35
35
|
"configuration": {
|
|
36
36
|
"title": "Claude",
|
|
37
37
|
"properties": {
|
|
38
|
-
"claude.
|
|
39
|
-
"type": "
|
|
40
|
-
"
|
|
38
|
+
"claude.modelProviders": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"format": "coda-model-provider",
|
|
43
|
+
"modelApis": ["anthropic-messages"]
|
|
44
|
+
},
|
|
41
45
|
"title": "模型服务",
|
|
42
46
|
"order": 1,
|
|
43
|
-
"description": "
|
|
47
|
+
"description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Claude 自己的模型照旧。只列 Anthropic 接口(anthropic-messages)的服务。"
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
},
|