@coda-console/claude 1.3.1 → 1.4.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/auth.mjs +1 -1
- package/agent/providers.mjs +53 -0
- package/agent/session.mjs +90 -26
- package/agent/waits.mjs +2 -0
- package/package.json +13 -1
package/agent/auth.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { open } from 'node:fs/promises';
|
|
|
3
3
|
import { isAbsolute } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Variables that make the Claude CLI use something other than the user's normal login.
|
|
6
|
-
const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
|
|
6
|
+
export const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
|
|
7
7
|
'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_OAUTH_SCOPES',
|
|
8
8
|
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
|
|
9
9
|
|
|
@@ -0,0 +1,53 @@
|
|
|
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.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { authOverrides } from './auth.mjs';
|
|
7
|
+
|
|
8
|
+
export const SETTING = 'claude.modelProvider';
|
|
9
|
+
const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
|
|
10
|
+
const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
|
|
11
|
+
const LIST_TIMEOUT = 8000;
|
|
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 };
|
|
25
|
+
}
|
|
26
|
+
|
|
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.
|
|
29
|
+
export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
|
|
30
|
+
const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
|
|
31
|
+
const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
|
|
32
|
+
if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
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
|
+
}
|
|
37
|
+
|
|
38
|
+
// The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
|
|
39
|
+
// beneath them): the service as its API, a token of the service's own (`none` without one), and the model for every role,
|
|
40
|
+
// subagents' and background work's included. None of the machine's own ways to the API goes with it: an API key, a key
|
|
41
|
+
// helper, an OAuth token or custom headers would be sent to the service, a cloud provider's would take the requests away.
|
|
42
|
+
export function serviceSettings({ url, key }, model) {
|
|
43
|
+
return { apiKeyHelper: '', env: { ...Object.fromEntries(authOverrides.map(name => [name, ''])), ANTHROPIC_BASE_URL: url, ANTHROPIC_AUTH_TOKEN: key ?? 'none',
|
|
44
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
45
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model } };
|
|
46
|
+
}
|
|
47
|
+
|
|
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 []; }
|
|
53
|
+
}
|
package/agent/session.mjs
CHANGED
|
@@ -11,6 +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
15
|
import { SessionTitle } from './titles.mjs';
|
|
15
16
|
import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
|
|
16
17
|
import { permissionRequest } from './permissions.mjs';
|
|
@@ -58,6 +59,8 @@ export class ClaudeSession {
|
|
|
58
59
|
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
|
|
59
60
|
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
60
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;
|
|
61
64
|
this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
|
|
62
65
|
// Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
|
|
63
66
|
this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
|
|
@@ -74,7 +77,12 @@ export class ClaudeSession {
|
|
|
74
77
|
.onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
|
|
75
78
|
.onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
|
|
76
79
|
.onRequest('session/prompt', ({ params }) => this.prompt(params))
|
|
77
|
-
.onNotification('session/cancel', ({ params }) => {
|
|
80
|
+
.onNotification('session/cancel', ({ params }) => {
|
|
81
|
+
if (params.sessionId !== this.sessionId) return;
|
|
82
|
+
// While the session moves to another query there is none to interrupt: the turn waiting on the move is stopped there.
|
|
83
|
+
if (this.rerouting) this.cancelling = true;
|
|
84
|
+
void this.query?.interrupt().catch(() => {});
|
|
85
|
+
})
|
|
78
86
|
.onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
|
|
79
87
|
.connect(stream);
|
|
80
88
|
this.client = this.connection.client;
|
|
@@ -114,7 +122,8 @@ export class ClaudeSession {
|
|
|
114
122
|
// Coda's servers are stdio ones; the SDK keys them by name.
|
|
115
123
|
const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
|
|
116
124
|
[name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
|
|
117
|
-
|
|
125
|
+
// What every query of the session starts with; a model of the chosen service runs in a query of its own (`reroute`).
|
|
126
|
+
this.launch = {
|
|
118
127
|
// Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
|
|
119
128
|
permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
|
|
120
129
|
// Coda's session instructions (what its clients render, what the project's plugins say) follow Claude Code's own.
|
|
@@ -124,7 +133,6 @@ export class ClaudeSession {
|
|
|
124
133
|
settings: { crossSessionInbound: 'refuse' },
|
|
125
134
|
includePartialMessages: true, forwardSubagentText: true, cwd: dir,
|
|
126
135
|
...(Object.keys(servers).length ? { mcpServers: servers } : {}), ...(additionalDirectories.length ? { additionalDirectories } : {}),
|
|
127
|
-
...(resume ? { resume } : { sessionId: this.sessionId }),
|
|
128
136
|
// Claude Code reports running and idle only when asked: idle is what ends a task's wait.
|
|
129
137
|
env: { ...environment, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' },
|
|
130
138
|
// Stop supplies the complete list of scheduled wakeups. A CronCreate result alone cannot tell whether one was later
|
|
@@ -137,15 +145,12 @@ export class ClaudeSession {
|
|
|
137
145
|
canUseTool: (toolName, input, context) => this.permission(toolName, input, context),
|
|
138
146
|
onElicitation: (request, context) => this.elicitation(request, context),
|
|
139
147
|
stderr: data => process.stderr.write(data),
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const initialization = await
|
|
144
|
-
this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
|
|
145
|
-
new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
|
|
146
|
-
.finally(() => clearTimeout(timer));
|
|
148
|
+
};
|
|
149
|
+
const listed = listedModels(this.service);
|
|
150
|
+
this.start(resume ? { resume } : { sessionId: this.sessionId });
|
|
151
|
+
const initialization = await this.initialized(), served = await listed;
|
|
147
152
|
if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
|
|
148
|
-
this.models = claudeModels(initialization.models); this.commands = initialization.commands ?? [];
|
|
153
|
+
this.models = [...claudeModels(initialization.models), ...served]; this.commands = initialization.commands ?? [];
|
|
149
154
|
// Who the session runs as, for the「Claude」panel (limits.mjs): the windows it reports are that account's, and
|
|
150
155
|
// only while the panel is of that account.
|
|
151
156
|
const email = initialization.account?.email;
|
|
@@ -159,6 +164,49 @@ export class ClaudeSession {
|
|
|
159
164
|
throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
|
|
160
165
|
}
|
|
161
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
|
|
168
|
+
// service, which it runs with that service as its API and that model for every role. One started after the session
|
|
169
|
+
// opened goes on with the model, mode and effort chosen.
|
|
170
|
+
start(at, model) {
|
|
171
|
+
this.input = new InputStream();
|
|
172
|
+
const entry = this.served(model);
|
|
173
|
+
const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
|
|
174
|
+
...(entry ? serviceSettings(this.service, entry.model) : {}) };
|
|
175
|
+
this.serviceModel = entry ? model : null;
|
|
176
|
+
this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
|
|
177
|
+
...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
|
|
178
|
+
this.pump = this.consume(this.query);
|
|
179
|
+
}
|
|
180
|
+
async initialized() {
|
|
181
|
+
let timer;
|
|
182
|
+
return Promise.race([this.query.initializationResult(),
|
|
183
|
+
this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
|
|
184
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
|
|
185
|
+
.finally(() => clearTimeout(timer));
|
|
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
|
|
188
|
+
// query started for it, resuming what the last one said. Before a turn, as Coda sets a session's model before each.
|
|
189
|
+
async reroute() {
|
|
190
|
+
const previous = this.query;
|
|
191
|
+
this.query = null; this.input.close();
|
|
192
|
+
try { previous.close(); } catch {}
|
|
193
|
+
await this.pump;
|
|
194
|
+
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
195
|
+
// One that cannot be started, or does not start, ends the session, as one that ends of itself does: no later turn waits on it.
|
|
196
|
+
try {
|
|
197
|
+
const said = await this.sdk.getSessionInfo(this.sessionId, { dir: this.cwd });
|
|
198
|
+
this.start(said ? { resume: this.sessionId } : { sessionId: this.sessionId }, this.current.model);
|
|
199
|
+
if (this.title) this.title.query = this.query;
|
|
200
|
+
await this.initialized();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.end(this.query, error);
|
|
203
|
+
throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The chosen service's model that `model` is; null for one of Claude's own.
|
|
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.
|
|
209
|
+
rerouted() { return (this.served(this.current.model)?.id ?? null) !== this.serviceModel; }
|
|
162
210
|
async configure(configId, value) {
|
|
163
211
|
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
164
212
|
if (configId === 'mode') {
|
|
@@ -166,7 +214,12 @@ export class ClaudeSession {
|
|
|
166
214
|
await this.query.setPermissionMode(value); this.current.mode = value;
|
|
167
215
|
} else if (configId === 'model') {
|
|
168
216
|
if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
|
|
169
|
-
|
|
217
|
+
// Between Claude's own models the query switches; to or from the service's, the next turn runs in another one, and
|
|
218
|
+
// what this one still runs would end with it.
|
|
219
|
+
const switching = (this.served(value)?.id ?? null) !== this.serviceModel;
|
|
220
|
+
if (switching && (this.prompts.size || this.waits.pending)) throw failure('后台任务、子 Agent 或定时唤醒还没结束,结束后才能换到这个模型', -32602);
|
|
221
|
+
if (!switching && !this.serviceModel) await this.query.setModel(value);
|
|
222
|
+
this.current.model = value;
|
|
170
223
|
// An effort chosen for the last model that this one does not have is no choice of it: the model's own goes on.
|
|
171
224
|
if (this.current.effort !== null && !this.models.find(model => model.id === value).efforts.includes(this.current.effort)) {
|
|
172
225
|
await this.query.applyFlagSettings({ effortLevel: null }); this.current.effort = null;
|
|
@@ -179,8 +232,14 @@ export class ClaudeSession {
|
|
|
179
232
|
return { configOptions: this.options() };
|
|
180
233
|
}
|
|
181
234
|
// Answered when the native result that names this input arrives; accepting it into the input says nothing yet.
|
|
182
|
-
prompt({ prompt }) {
|
|
235
|
+
async prompt({ prompt }) {
|
|
183
236
|
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
237
|
+
if (this.rerouted()) {
|
|
238
|
+
if (!this.rerouting) { this.cancelling = false; this.rerouting = this.reroute().finally(() => { this.rerouting = null; }); }
|
|
239
|
+
await this.rerouting;
|
|
240
|
+
// Stopped while it moved: the input is not given to the new query.
|
|
241
|
+
if (this.cancelling) { this.cancelling = false; return { stopReason: 'cancelled' }; }
|
|
242
|
+
}
|
|
184
243
|
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
185
244
|
const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
|
|
186
245
|
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
|
|
@@ -222,23 +281,27 @@ export class ClaudeSession {
|
|
|
222
281
|
} catch { return fallback; } finally { signal?.removeEventListener('abort', withdraw); this.withdrawals.delete(withdrawn); }
|
|
223
282
|
}
|
|
224
283
|
|
|
225
|
-
async consume() {
|
|
284
|
+
async consume(query) {
|
|
226
285
|
let error;
|
|
227
286
|
try {
|
|
228
|
-
for await (const message of
|
|
287
|
+
for await (const message of query) {
|
|
229
288
|
try { this.receive(message); }
|
|
230
289
|
catch (failed) { process.stderr.write(`Claude 消息处理失败:${failed.message}\n`); }
|
|
231
290
|
}
|
|
232
291
|
} catch (value) { if (this.phase !== 'closing') error = value; }
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
292
|
+
// The session ends with its query; one it moved off, for another model's, ends with nothing to say.
|
|
293
|
+
if (query === this.query) this.end(query, error);
|
|
294
|
+
}
|
|
295
|
+
// The session ends with its query: what waits on it is let go, and the entry told unless it was closing.
|
|
296
|
+
end(query, error) {
|
|
297
|
+
if (this.phase === 'ended') return;
|
|
298
|
+
const intended = this.phase === 'closing';
|
|
299
|
+
this.phase = 'ended'; this.input.close();
|
|
300
|
+
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
301
|
+
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
302
|
+
this.prompts.clear();
|
|
303
|
+
try { query?.close(); } catch {}
|
|
304
|
+
if (!intended) this.onEnded(error);
|
|
242
305
|
}
|
|
243
306
|
receive(m) {
|
|
244
307
|
const parent = m.parent_tool_use_id ?? null;
|
|
@@ -257,8 +320,9 @@ export class ClaudeSession {
|
|
|
257
320
|
case 'init': {
|
|
258
321
|
if (m.session_id !== this.sessionId) return;
|
|
259
322
|
this.terminal = m.terminal_slash_commands ?? [];
|
|
260
|
-
// Its effort is the one applied, the model's own included: what was chosen stays what it was.
|
|
261
|
-
|
|
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.
|
|
325
|
+
this.current = { ...this.current, model: this.serviceModel ?? reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
|
|
262
326
|
if (!this.title) {
|
|
263
327
|
const sessionId = this.sessionId;
|
|
264
328
|
this.title = new SessionTitle({ sdk: this.sdk, query: this.query, cwd: this.cwd, sessionId, alive: () => ['starting', 'running'].includes(this.phase),
|
package/agent/waits.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export class ClaudeWaits {
|
|
|
21
21
|
this.models = new Map(); this.outputs = new Map();
|
|
22
22
|
}
|
|
23
23
|
get handling() { return this.own.size > 0 || [...this.tasks.values(), ...this.crons.values()].some(w => w.state === 'handling'); }
|
|
24
|
+
// Whether anything is still waited on: what ends with the Claude process that runs it.
|
|
25
|
+
get pending() { return this.own.size > 0 || [...this.tasks.values(), ...this.crons.values()].some(w => open(w.state)); }
|
|
24
26
|
report(wait, state, fields = {}) {
|
|
25
27
|
wait.state = state;
|
|
26
28
|
const params = { waitId: wait.waitId, state, ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) };
|
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.4.1",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
9
9
|
"@anthropic-ai/claude-agent-sdk": "0.3.296"
|
|
@@ -32,6 +32,18 @@
|
|
|
32
32
|
"setupHint": "请在运行机配置 Claude SDK 认证;若指定了 token 文件,请检查路径和权限。"
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
|
+
"configuration": {
|
|
36
|
+
"title": "Claude",
|
|
37
|
+
"properties": {
|
|
38
|
+
"claude.modelProvider": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"format": "coda-model-provider",
|
|
41
|
+
"title": "模型服务",
|
|
42
|
+
"order": 1,
|
|
43
|
+
"description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Claude 自己的模型照旧。只接 Anthropic 接口(anthropic-messages)的服务。"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
35
47
|
"panels": [
|
|
36
48
|
{
|
|
37
49
|
"id": "limits",
|