@toddzheng024/dscode-desktop 0.7.41 → 0.7.44
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/README.md +2 -2
- package/cordis.patch.yml +11 -2
- package/docs/CHANGELOG.md +39 -0
- package/docs/antigravity.md +41 -0
- package/docs/distribution.md +6 -4
- package/docs/dscode-ultra.md +16 -3
- package/docs/openai.md +64 -0
- package/docs/releases/0.7.41.md +5 -2
- package/docs/releases/0.7.42.md +23 -0
- package/docs/releases/0.7.43.md +41 -0
- package/docs/releases/0.7.44.md +43 -0
- package/docs/tui-commands.md +13 -8
- package/package.json +4 -1
- package/plugins/antigravity/NOTICE +25 -0
- package/plugins/antigravity/adapter.mjs +79 -0
- package/plugins/antigravity/client.mjs +73 -0
- package/plugins/antigravity/command.mjs +79 -0
- package/plugins/antigravity/index.mjs +49 -0
- package/plugins/antigravity/models.mjs +80 -0
- package/plugins/antigravity/oauth.mjs +130 -0
- package/plugins/antigravity/wire.mjs +155 -0
- package/plugins/compaction/engine.mjs +4 -3
- package/plugins/compaction/threshold.mjs +10 -2
- package/plugins/credentials/index.mjs +7 -0
- package/plugins/dscode/index.mjs +3 -2
- package/plugins/openai/adapter.mjs +52 -0
- package/plugins/openai/auth.mjs +81 -0
- package/plugins/openai/command.mjs +66 -0
- package/plugins/openai/http.mjs +29 -0
- package/plugins/openai/index.mjs +58 -0
- package/plugins/openai/models.mjs +59 -0
- package/plugins/openai/oauth.mjs +82 -0
- package/plugins/openai/speed.d.mts +2 -0
- package/plugins/openai/speed.mjs +48 -0
- package/plugins/openai/store.mjs +44 -0
- package/plugins/openai/wire.mjs +93 -0
- package/plugins/providers/catalog.mjs +2 -0
- package/plugins/providers/startup-commands.d.mts +6 -0
- package/plugins/providers/startup-commands.mjs +27 -0
- package/vendor/persistent/index.js +3 -20
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import { thinkingConfig } from './models.mjs';
|
|
4
|
+
|
|
5
|
+
export const REPLAY_KIND = 'dscode-antigravity';
|
|
6
|
+
const textOf = blocks => blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
7
|
+
const signature = value => typeof value === 'string' && value.length > 0;
|
|
8
|
+
|
|
9
|
+
function imageParts(blocks, images) {
|
|
10
|
+
return blocks.flatMap(block => {
|
|
11
|
+
if (block.type === 'text') return block.text ? [{ text: block.text }] : [];
|
|
12
|
+
if (block.type !== 'image') return [];
|
|
13
|
+
const version = images?.versions.get(block.attachment.attachmentId);
|
|
14
|
+
if (!version) throw new LlmError('Antigravity image was not prepared by the attachment service', 'UNSUPPORTED_CONTENT');
|
|
15
|
+
return [{ text: requestImageHandleText(block.attachment, version, images.access?.(block.attachment)) },
|
|
16
|
+
{ inlineData: { mimeType: version.mediaType, data: Buffer.from(version.data).toString('base64') } }];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preserve signed model turns verbatim. On a provider/model switch, represent
|
|
21
|
+
* earlier unsigned calls as historical text, not fresh executable requests. */
|
|
22
|
+
export function convertMessages(options, route, images) {
|
|
23
|
+
const contents = [], calls = new Map();
|
|
24
|
+
const append = (role, parts) => {
|
|
25
|
+
if (!parts.length) return;
|
|
26
|
+
if (contents.at(-1)?.role === role) contents.at(-1).parts.push(...parts);
|
|
27
|
+
else contents.push({ role, parts });
|
|
28
|
+
};
|
|
29
|
+
const result = (id, blocks, isError) => {
|
|
30
|
+
const call = calls.get(id), parts = imageParts(blocks, images);
|
|
31
|
+
const output = parts.filter(p => p.text !== undefined).map(p => p.text).join('\n') || '(no output)';
|
|
32
|
+
if (!call?.signed) {
|
|
33
|
+
append('user', [{ text: `Historical tool result (${call?.name ?? id}):\n${output}` }, ...parts.filter(p => p.inlineData)]);
|
|
34
|
+
} else {
|
|
35
|
+
append('user', [{ functionResponse: { name: call.name, response: { [isError ? 'error' : 'output']: output },
|
|
36
|
+
...(parts.some(p => p.inlineData) ? { parts: parts.filter(p => p.inlineData) } : {}) } }]);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for (const message of options.messages) {
|
|
40
|
+
if (message.role === 'system') continue;
|
|
41
|
+
if (message.role === 'tool') { result(message.toolCallId, message.content, message.isError); continue; }
|
|
42
|
+
if (message.role !== 'assistant') {
|
|
43
|
+
for (const block of message.content) {
|
|
44
|
+
if (block.type === 'tool-result') result(block.toolCallId, block.content, block.isError);
|
|
45
|
+
else append('user', imageParts([block], images));
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const source = message.source, replay = source?.replayState;
|
|
50
|
+
const same = source?.provider === options.provider && source?.model === options.model && replay?.response?.kind === REPLAY_KIND
|
|
51
|
+
&& replay.response.runtimeModel === route.id && replay.blocks?.length === message.content.length;
|
|
52
|
+
const firstCall = message.content.findIndex(b => b.type === 'tool-call');
|
|
53
|
+
const signedCalls = same && firstCall >= 0 && signature(replay.blocks[firstCall]?.thoughtSignature);
|
|
54
|
+
const parts = [];
|
|
55
|
+
message.content.forEach((block, index) => {
|
|
56
|
+
const sig = same ? replay.blocks[index]?.thoughtSignature : undefined;
|
|
57
|
+
if (block.type === 'text') {
|
|
58
|
+
if (block.text || sig) parts.push({ text: block.text, ...(sig ? { thoughtSignature: sig } : {}) });
|
|
59
|
+
} else if (block.type === 'reasoning' && same) {
|
|
60
|
+
parts.push({ thought: true, text: block.text, ...(sig ? { thoughtSignature: sig } : {}) });
|
|
61
|
+
} else if (block.type === 'tool-call') {
|
|
62
|
+
calls.set(block.id, { name: block.name, signed: signedCalls });
|
|
63
|
+
if (signedCalls) {
|
|
64
|
+
let args;
|
|
65
|
+
try { args = JSON.parse(block.arguments); } catch { throw new LlmError('Invalid saved Antigravity tool arguments', 'INVALID_REQUEST'); }
|
|
66
|
+
parts.push({ functionCall: { name: block.name, args }, ...(sig ? { thoughtSignature: sig } : {}) });
|
|
67
|
+
} else parts.push({ text: `Historical tool call: ${block.name}(${block.arguments})` });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
append('model', parts);
|
|
71
|
+
}
|
|
72
|
+
if (!contents.length || contents[0].role !== 'user') contents.unshift({ role: 'user', parts: [{ text: 'Continue using the following conversation history.' }] });
|
|
73
|
+
if (contents.at(-1).role === 'model') append('user', [{ text: 'Continue the active task.' }]);
|
|
74
|
+
return contents;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function requestBody(options, model, route, project, images) {
|
|
78
|
+
const contents = convertMessages(options, route, images);
|
|
79
|
+
const system = [options.system, ...options.messages.filter(m => m.role === 'system').map(m => textOf(m.content))].filter(Boolean).join('\n\n');
|
|
80
|
+
const seed = options.sessionId ?? createHash('sha256').update(JSON.stringify(contents[0])).digest('hex');
|
|
81
|
+
const hash = createHash('sha256').update(String(seed)).digest('hex');
|
|
82
|
+
const trajectory = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
83
|
+
const step = contents.length;
|
|
84
|
+
const labels = { last_step_index: String(step - 1), request_id: `${trajectory}-${options.messages.filter(m => m.role === 'assistant').length}`,
|
|
85
|
+
trajectory_id: trajectory, used_claude: 'false', used_claude_conservative: 'false', used_non_gemini_model: 'false',
|
|
86
|
+
...(route.modelEnum ? { model_enum: route.modelEnum } : {}) };
|
|
87
|
+
const thinking = thinkingConfig(route);
|
|
88
|
+
const tools = (options.tools ?? []).map(t => ({ name: t.name, description: t.description, parametersJsonSchema: t.parameters }));
|
|
89
|
+
return { project, model: route.id, requestType: 'agent', userAgent: 'antigravity',
|
|
90
|
+
requestId: `agent/${trajectory}/${Date.now()}/${trajectory}/${step}`,
|
|
91
|
+
request: { contents, sessionId: BigInt(`0x${hash.slice(0, 15)}`).toString(), labels,
|
|
92
|
+
...(system ? { systemInstruction: { role: 'user', parts: [{ text: system }] } } : {}),
|
|
93
|
+
generationConfig: { maxOutputTokens: Math.min(options.maxTokens ?? model.maxTokens, model.maxTokens),
|
|
94
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
95
|
+
...(options.stop ? { stopSequences: options.stop } : {}), ...(thinking ? { thinkingConfig: thinking } : {}) },
|
|
96
|
+
...(tools.length ? { tools: [{ functionDeclarations: tools }] } : {}) } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function usageOf(raw) {
|
|
100
|
+
const valid = n => Number.isSafeInteger(n) && n >= 0;
|
|
101
|
+
if (!valid(raw?.promptTokenCount) || !valid(raw?.candidatesTokenCount)) return undefined;
|
|
102
|
+
const cached = valid(raw.cachedContentTokenCount) ? raw.cachedContentTokenCount : 0;
|
|
103
|
+
const reasoning = valid(raw.thoughtsTokenCount) ? raw.thoughtsTokenCount : 0;
|
|
104
|
+
return { inputTokens: Math.max(0, raw.promptTokenCount - cached), outputTokens: raw.candidatesTokenCount + reasoning,
|
|
105
|
+
cacheReadTokens: cached, reasoningTokens: reasoning,
|
|
106
|
+
...(valid(raw.totalTokenCount) ? { totalTokens: raw.totalTokenCount } : {}) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** No tool block is committed before a successful terminal marker. Partial
|
|
110
|
+
* streams fail so a disconnected call can never become a local side effect. */
|
|
111
|
+
export async function* translate(payloads, { model, runtimeModel }) {
|
|
112
|
+
const blocks = [], metadata = [];
|
|
113
|
+
let active, finish, usage;
|
|
114
|
+
for await (const payload of payloads) {
|
|
115
|
+
if (payload === '[DONE]') break;
|
|
116
|
+
let packet;
|
|
117
|
+
try { packet = JSON.parse(payload); } catch { throw new LlmError('Malformed Antigravity stream', 'MALFORMED_RESPONSE'); }
|
|
118
|
+
if (packet.error || packet.response?.error) throw new LlmError('Antigravity returned a streaming error', 'SERVER');
|
|
119
|
+
const data = packet.response ?? packet, candidate = data.candidates?.[0];
|
|
120
|
+
if (data.promptFeedback?.blockReason) throw new LlmError('Antigravity blocked this request', 'CONTENT_FILTER');
|
|
121
|
+
for (const part of candidate?.content?.parts ?? []) {
|
|
122
|
+
// Google can send a signature in a separate part after its text/call.
|
|
123
|
+
if (!part.functionCall && part.text === undefined && signature(part.thoughtSignature) && active) {
|
|
124
|
+
metadata[active.index].thoughtSignature = part.thoughtSignature; continue;
|
|
125
|
+
}
|
|
126
|
+
const type = part.functionCall ? 'tool-call' : part.thought ? 'reasoning' : 'text';
|
|
127
|
+
if (!part.functionCall && part.text === undefined && !part.thoughtSignature) throw new LlmError('Unsupported Antigravity output part', 'UNSUPPORTED_CONTENT');
|
|
128
|
+
if (finish) throw new LlmError('Antigravity emitted content after finishing', 'MALFORMED_RESPONSE');
|
|
129
|
+
if (!active || active.block.type !== type || type === 'tool-call' || active.closed) {
|
|
130
|
+
const block = type === 'tool-call' ? { type, id: randomUUID(), name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args ?? {}) } : { type, text: '' };
|
|
131
|
+
if (type === 'tool-call' && (typeof block.name !== 'string' || !block.name || part.functionCall.args !== undefined && (typeof part.functionCall.args !== 'object' || Array.isArray(part.functionCall.args) || part.functionCall.args === null))) throw new LlmError('Invalid Antigravity tool call', 'MALFORMED_RESPONSE');
|
|
132
|
+
active = { index: blocks.length, block }; blocks.push(block); metadata.push({ type });
|
|
133
|
+
yield { type: 'block-start', index: active.index, blockType: type };
|
|
134
|
+
}
|
|
135
|
+
if (part.thoughtSignature) metadata[active.index].thoughtSignature = part.thoughtSignature;
|
|
136
|
+
if (type === 'tool-call') {
|
|
137
|
+
yield { type: 'tool-call-delta', index: active.index, id: active.block.id, name: active.block.name, argumentsDelta: active.block.arguments };
|
|
138
|
+
active.closed = true;
|
|
139
|
+
} else if (typeof part.text === 'string') {
|
|
140
|
+
active.block.text += part.text;
|
|
141
|
+
yield { type: type === 'text' ? 'text-delta' : 'reasoning-delta', index: active.index, text: part.text };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (candidate?.finishReason) finish = candidate.finishReason;
|
|
145
|
+
usage = usageOf(data.usageMetadata) ?? usage;
|
|
146
|
+
}
|
|
147
|
+
if (!finish) throw new LlmError('Antigravity stream ended before completion', 'TRANSPORT');
|
|
148
|
+
if (!['STOP', 'MAX_TOKENS'].includes(finish)) throw new LlmError('Antigravity did not complete the response', finish === 'SAFETY' ? 'CONTENT_FILTER' : 'MALFORMED_RESPONSE');
|
|
149
|
+
if (!blocks.length) throw new LlmError('Antigravity returned no content', 'EMPTY_RESPONSE');
|
|
150
|
+
if (finish === 'MAX_TOKENS' && blocks.some(b => b.type === 'tool-call')) throw new LlmError('Antigravity tool response reached the token limit; retry with a larger output budget', 'MALFORMED_RESPONSE');
|
|
151
|
+
for (let index = 0; index < blocks.length; index++) yield { type: 'block-end', index, block: blocks[index] };
|
|
152
|
+
if (usage) yield { type: 'usage', usage };
|
|
153
|
+
yield { type: 'finish', reason: { kind: finish === 'MAX_TOKENS' ? 'max-tokens' : blocks.some(b => b.type === 'tool-call') ? 'tool-calls' : 'stop' },
|
|
154
|
+
replayState: { response: { kind: REPLAY_KIND, version: 1, model, runtimeModel }, blocks: metadata } };
|
|
155
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
|
|
2
2
|
import { ManualCompactionError, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
|
|
3
3
|
import { SessionSeq } from '@deepseek-ai/dsh-session';
|
|
4
|
-
import { effectiveContextWindow, fitsInWindow, prefetchThresholdTokens, pricedCompactionPolicy } from './threshold.mjs';
|
|
4
|
+
import { effectiveContextWindow, fitsInWindow, prefetchThresholdTokens, pricedCompactionPolicy, compactionThresholdTokens } from './threshold.mjs';
|
|
5
5
|
|
|
6
6
|
// DSCODE's compaction policy as a subclass of the upstream engine instead of a
|
|
7
7
|
// build-time patch of its source. It owns exactly the decisions this deployment
|
|
@@ -157,8 +157,9 @@ export class DscodeCompactionEngine extends BasicCompactionEngine {
|
|
|
157
157
|
dscodeCompactSpec(policy, contextWindow) {
|
|
158
158
|
const key = `${policy.target.provider}/${policy.target.model}`;
|
|
159
159
|
if (!Number.isInteger(contextWindow) || contextWindow <= 0) throw new Error(`compaction: contextWindow (${contextWindow}) must be a positive integer for ${key}`);
|
|
160
|
-
const thresholdTokens =
|
|
161
|
-
const
|
|
160
|
+
const thresholdTokens = compactionThresholdTokens(policy.target.provider, policy.target.model, contextWindow, policy.thresholdRatio);
|
|
161
|
+
const retentionWindow = Math.min(contextWindow, thresholdTokens / policy.thresholdRatio);
|
|
162
|
+
const retainTokens = policy.retainTokens === undefined ? Math.floor(retentionWindow * policy.retainRatio) : policy.retainTokens;
|
|
162
163
|
if (retainTokens >= thresholdTokens) throw new Error(`compaction: retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens} for ${key}`);
|
|
163
164
|
return { ...policy, contextWindow, thresholdTokens, retainTokens };
|
|
164
165
|
}
|
|
@@ -9,6 +9,14 @@ import { ensureOpenRouterModels } from '../openrouter/models.mjs';
|
|
|
9
9
|
// so compaction waits; with no discount it pays off early.
|
|
10
10
|
export const DEFAULT_THRESHOLD_RATIO = 0.8;
|
|
11
11
|
|
|
12
|
+
// A pressure trigger, not an advertised model capacity. Smaller models still
|
|
13
|
+
// compact earlier when their safe input budget requires it.
|
|
14
|
+
export const CODEX_COMPACTION_TOKENS = 272000;
|
|
15
|
+
export function compactionThresholdTokens(provider, model, contextWindow, thresholdRatio) {
|
|
16
|
+
const ordinary = Math.floor(contextWindow * thresholdRatio);
|
|
17
|
+
return provider === 'openai' ? Math.min(CODEX_COMPACTION_TOKENS, ordinary) : ordinary;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
/** Threshold ratio for a cache-read / input price ratio; unknown prices keep the upstream default. */
|
|
13
21
|
export function thresholdForCacheRatio(ratio) {
|
|
14
22
|
if (!Number.isFinite(ratio) || ratio < 0) return DEFAULT_THRESHOLD_RATIO;
|
|
@@ -92,8 +100,8 @@ export async function pricedCompactionPolicy(config, policy, now = Date.now()) {
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/** Whether the session's current context would compact on a route's next step, or undefined when either size is unknown. */
|
|
95
|
-
export function compactionPreview({ used, contextWindow, thresholdRatio, label = '' }) {
|
|
103
|
+
export function compactionPreview({ used, contextWindow, thresholdRatio, label = '', provider, model }) {
|
|
96
104
|
if (!Number.isFinite(used) || used <= 0 || !Number.isInteger(contextWindow) || contextWindow <= 0) return undefined;
|
|
97
|
-
const threshold =
|
|
105
|
+
const threshold = compactionThresholdTokens(provider, model, contextWindow, thresholdRatio);
|
|
98
106
|
return { label, used, contextWindow, thresholdRatio, threshold, compacts: used >= threshold, overflows: used >= contextWindow };
|
|
99
107
|
}
|
|
@@ -53,6 +53,13 @@ export default class DscodeCredentials extends LocalCredentialProvider {
|
|
|
53
53
|
return super.resolve(ref);
|
|
54
54
|
}
|
|
55
55
|
async describe(ref) {
|
|
56
|
+
if (ref === 'OPENAI_API_KEY') {
|
|
57
|
+
const openai = this.ctx.get('dscodeOpenAI');
|
|
58
|
+
if (openai) {
|
|
59
|
+
const state = openai.auth.status();
|
|
60
|
+
if (state.mode === 'chatgpt') return { configured: state.configured, source: 'account', writable: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
56
63
|
if (this.desktop) {
|
|
57
64
|
const native = await super.describe(ref);
|
|
58
65
|
if (native.configured) return LOGINS.has(ref) ? { ...native, source: 'account', writable: false } : native;
|
package/plugins/dscode/index.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { withChildTarget } from './child-target.mjs';
|
|
|
12
12
|
import { registerStatus } from '../status/index.mjs';
|
|
13
13
|
export const name = 'dscode-execution-policy';
|
|
14
14
|
export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals', 'subagents', 'llm'];
|
|
15
|
-
export const SHELL_POLICY = `Use
|
|
15
|
+
export const SHELL_POLICY = `Use read to inspect text files (offset is 1-based; limit selects a line window), edit for targeted literal replacements (old_string/new_string), and write to create files or intentionally replace their full contents. Read an existing file before editing or overwriting it; a shell read or search does not satisfy the file-version guard. Successful edits and writes update that observation. If a file changed externally, re-read it and reconcile the changes before retrying. If old_string has multiple matches, add enough context to make it unique or use replace_all only when every occurrence should change. Never switch to a shell edit just to bypass a stale-file, unread-file or permission rejection. Observations do not survive session resume. File-tool relative paths resolve against the session workspace, not the persistent shell's current directory; prefer absolute paths when mixing the two.
|
|
16
|
+
Use bash as the persistent shell for rg/rg --files searches, builds, tests and batch operations. Commands receive EOF on stdin unless a pipeline, heredoc or redirection provides input. Use non-interactive flags; this tool cannot answer terminal prompts. For changes that need a multi-file patch, apply_patch remains available with a standard unified diff on stdin (git apply format, a/ and b/ paths); apply_patch --check validates before writing. It is not the *** Begin Patch format and does not use the file tools' observed-version guard. Quote heredoc delimiters to avoid shell interpolation.
|
|
16
17
|
Each agent has its own persistent shell, initially in the session workspace. cd, exported variables, functions and background jobs persist only while this shell lives. Timeout, cancellation, exit, /shell reset and process restart discard shell state; resume restores conversation, not an OS process. Never assume an environment from a past session still exists. Inspect pwd when paths matter. Keep long-running processes controlled and clean them up when done.
|
|
17
18
|
Normal bash remains confined by the active sandbox. After a genuine sandbox denial, shell_retry provides a fresh, one-shot shell with the existing approval/escalation mechanism. Set an explicit absolute workdir and reconstruct needed non-secret setup; it does not inherit the persistent shell's cd, exports, functions or jobs. Use existing credential-aware CLIs; never paste secrets into arguments. Approval rejection is final for that action; do not work around it.
|
|
18
19
|
Delegation to child agents (subagent, subagent_fork) is available at every effort. Below ultra, delegation is the exception: do the work in this agent by default. Delegate only a substantial, independent part of the task whose parallel work clearly shortens completion, or a broad read-only investigation that would otherwise crowd this context; never delegate a bounded edit, a single-file change, a quick lookup, one test run or a routine review. Below ultra run at most one child at a time, give it a bounded objective, choose the lowest reasoning_effort the model offers that fits, and verify and integrate its result yourself. Ultra adds its own delegation guidance to the request; the preset allows one delegation level and the runtime caps a parent at five concurrently running children below ultra and twenty at ultra.`;
|
|
@@ -60,7 +61,7 @@ const childLimit = owner => (owner.session.requestHeader?.()?.config ?? owner.op
|
|
|
60
61
|
// repository there instead of pointing at a path that does not exist.
|
|
61
62
|
const DOCS_DIR = fileURLToPath(new URL('../../docs/', import.meta.url));
|
|
62
63
|
const DOCS_REPO = 'https://github.com/qiz029/dscode/blob/main/docs/';
|
|
63
|
-
export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/triggers-design.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
|
|
64
|
+
export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, antigravity.md, openai.md, custom-providers.md, demo.md, triggers.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, antigravity.md, openai.md, custom-providers.md, demo.md, triggers.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/triggers-design.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
|
|
64
65
|
|
|
65
66
|
export function apply(ctx, config = {}) {
|
|
66
67
|
registerStatus(ctx);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { isAttachmentError } from '@deepseek-ai/dsh-attachment';
|
|
3
|
+
import { sseData } from '../providers/chat-stream.mjs';
|
|
4
|
+
import { prepareImages } from '../custom/images.mjs';
|
|
5
|
+
import { API, responseError } from './http.mjs';
|
|
6
|
+
import { requestBody, translate } from './wire.mjs';
|
|
7
|
+
export class OpenAIAdapter extends LlmAdapter {
|
|
8
|
+
constructor(config) { super(); this.config = config; }
|
|
9
|
+
providerInfo(provider) { return { id: provider, name: 'OpenAI / ChatGPT' }; }
|
|
10
|
+
providerRetryPolicy() { return this.config.options().retryPolicy; }
|
|
11
|
+
async listModels(provider) { return (await this.config.models.list()).map(m => ({ provider, id: m.id, name: m.name, inputModalities: m.inputModalities })); }
|
|
12
|
+
async resolveModel(provider, id) {
|
|
13
|
+
const m = await this.config.models.get(id);
|
|
14
|
+
return { provider, id, name: m.name, context: { contextWindow: m.contextWindow }, defaultMaxTokens: m.maxTokens, inputModalities: m.inputModalities,
|
|
15
|
+
...(m.efforts.length ? { reasoning: { efforts: m.efforts.map(id => ({ id: ReasoningEffortId(id), name: id })), defaultEffort: ReasoningEffortId(m.efforts.includes(m.defaultEffort) ? m.defaultEffort : m.efforts[0]) } } : {}) };
|
|
16
|
+
}
|
|
17
|
+
async *stream(options) {
|
|
18
|
+
const config = this.config, settings = config.options(), controller = new AbortController();
|
|
19
|
+
const signal = AbortSignal.any([controller.signal, ...(options.signal ? [options.signal] : [])]);
|
|
20
|
+
const release = config.registerRequest?.(controller);
|
|
21
|
+
let timer, timedOut = false;
|
|
22
|
+
const pulse = ms => { clearTimeout(timer); timer = setTimeout(() => { timedOut = true; controller.abort(); }, ms); timer.unref?.(); };
|
|
23
|
+
try {
|
|
24
|
+
const auth = await config.auth.acquire(signal), model = await config.models.get(options.model, auth, signal);
|
|
25
|
+
const images = await prepareImages(options.messages, model, config.resolveAttachments?.(), config.resolveImageAccess, signal);
|
|
26
|
+
const requested = config.speed(options), body = requestBody({ ...options, messages: images.messages }, model, auth, requested, images);
|
|
27
|
+
signal.throwIfAborted(); pulse(settings.headerTimeoutMs);
|
|
28
|
+
const response = await config.fetch(API + '/responses', { method: 'POST', redirect: 'error', headers: { authorization: 'Bearer ' + auth.token, 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify(body), signal });
|
|
29
|
+
if (!response.ok) { let data; try { data = await response.json(); } catch {} throw responseError(response.status, data, response.headers.get('x-request-id')); }
|
|
30
|
+
const mediaType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
|
|
31
|
+
// The ChatGPT direct route can deliver valid SSE without Content-Type.
|
|
32
|
+
// Missing metadata is not proof of a malformed body: the translator still
|
|
33
|
+
// requires response.completed and validates every item before committing.
|
|
34
|
+
if (!response.body || mediaType && mediaType !== 'text/event-stream') {
|
|
35
|
+
const label = mediaType && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType) ? mediaType.slice(0,100) : 'unknown content type';
|
|
36
|
+
throw new LlmError(`OpenAI did not return a stream (HTTP ${response.status}; ${response.body ? label : 'empty body'})`, 'MALFORMED_RESPONSE');
|
|
37
|
+
}
|
|
38
|
+
pulse(settings.streamIdleTimeoutMs);
|
|
39
|
+
for await (const chunk of translate(sseData(response.body, () => pulse(settings.streamIdleTimeoutMs)), { model: model.id, account: auth.key, requested: body.service_tier })) {
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
clearTimeout(timer); yield chunk; pulse(settings.streamIdleTimeoutMs);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (options.signal?.aborted) throw new LlmError('OpenAI request cancelled', 'ABORTED');
|
|
45
|
+
if (timedOut) throw new LlmError('OpenAI connection timed out; retry', 'TIMEOUT');
|
|
46
|
+
if (controller.signal.aborted) throw new LlmError('OpenAI account changed or signed out', 'ABORTED');
|
|
47
|
+
if (isAttachmentError(error)) throw new LlmError('OpenAI image input failed: ' + error.code, error.code);
|
|
48
|
+
if (error instanceof LlmError) throw error;
|
|
49
|
+
throw new LlmError('Could not complete the OpenAI request', 'TRANSPORT');
|
|
50
|
+
} finally { clearTimeout(timer); controller.abort(); release?.(); }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { tokenRequest } from './http.mjs';
|
|
4
|
+
import { tokenFields } from './oauth.mjs';
|
|
5
|
+
const terminal = new Set(['invalid_grant','invalid_refresh_token','token_expired','refresh_token_expired','refresh_token_invalidated','refresh_token_reused']);
|
|
6
|
+
const strip = account => { const { access: _access, refresh: _refresh, expires: _expires, idToken: _idToken, scopes: _scopes, ...registration } = account; return registration; };
|
|
7
|
+
export class OpenAIAuth {
|
|
8
|
+
constructor({ store, apiKey, verifier, fetch = globalThis.fetch }) { Object.assign(this, { store, apiKey, verifier, fetch }); }
|
|
9
|
+
status() {
|
|
10
|
+
const data = this.store.read();
|
|
11
|
+
const account = data?.accounts.find(a => a.clientId === data.active);
|
|
12
|
+
return { mode: data?.mode ?? 'chatgpt', account, accounts: data?.accounts ?? [], configured: Boolean(account?.access && account.scopes?.includes('chatgpt.tokens.use.direct')) };
|
|
13
|
+
}
|
|
14
|
+
async select(mode, id) {
|
|
15
|
+
return this.store.locked(data => {
|
|
16
|
+
if (mode === 'chatgpt' && id && !data.accounts.some(a => a.clientId === id)) throw Error('Unknown OpenAI account');
|
|
17
|
+
data.mode = mode; if (id) data.active = id; delete data.pendingRegistration; data.revision++; this.store.write(data);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async rememberRegistration(clientId, tx, signal) {
|
|
21
|
+
if (tx.clientId !== 'dynamic_agent_client') return;
|
|
22
|
+
return this.store.locked(data => {
|
|
23
|
+
if (data.revision !== tx.revision || signal?.aborted) return;
|
|
24
|
+
// A callback's client ID is a retry hint, not a verified account or grant.
|
|
25
|
+
data.pendingRegistration = { clientId, revision: data.revision };
|
|
26
|
+
this.store.write(data);
|
|
27
|
+
}, signal);
|
|
28
|
+
}
|
|
29
|
+
async save(account, tx, signal) {
|
|
30
|
+
return this.store.locked(data => {
|
|
31
|
+
if (data.revision !== tx.revision || signal?.aborted) return false;
|
|
32
|
+
const old = data.accounts.find(a => a.clientId === account.clientId);
|
|
33
|
+
if (old && old.subject !== account.subject) throw Error('OpenAI registration identity changed');
|
|
34
|
+
data.accounts = data.accounts.filter(a => a.clientId !== account.clientId); data.accounts.push(account);
|
|
35
|
+
data.active = account.clientId; data.mode = 'chatgpt'; delete data.pendingRegistration; data.revision++; this.store.write(data); return true;
|
|
36
|
+
}, signal);
|
|
37
|
+
}
|
|
38
|
+
async acquire(signal) {
|
|
39
|
+
const status = this.status();
|
|
40
|
+
if (status.mode === 'api-key') {
|
|
41
|
+
const key = await this.apiKey();
|
|
42
|
+
if (!key || /\s/.test(key)) throw new LlmError('Configure OPENAI_API_KEY with /login openai or in the launch environment', 'MISSING_CREDENTIAL');
|
|
43
|
+
return { mode: 'api-key', token: key, key: 'api-key:' + createHash('sha256').update(key).digest('hex') };
|
|
44
|
+
}
|
|
45
|
+
// Hold the kernel lock across refresh, re-read after admission, then commit
|
|
46
|
+
// the rotating token as a single atomic replacement. Other Hosts wait.
|
|
47
|
+
return this.store.locked(async data => {
|
|
48
|
+
if (data.mode !== 'chatgpt') throw new LlmError('OpenAI billing selection changed; retry', 'AUTH');
|
|
49
|
+
let account = data.accounts.find(a => a.clientId === data.active);
|
|
50
|
+
if (!account?.access || !account.refresh) throw new LlmError('Run /openai login to sign in with ChatGPT', 'MISSING_CREDENTIAL');
|
|
51
|
+
if (account.expires - Date.now() < 60000) {
|
|
52
|
+
try {
|
|
53
|
+
const token = await tokenRequest({ grant_type: 'refresh_token', client_id: account.clientId, refresh_token: account.refresh }, this.fetch, signal);
|
|
54
|
+
if (token.id_token) await this.verifier.verify(token.id_token, account, { refresh: true });
|
|
55
|
+
account = { ...account, ...tokenFields(token, account) };
|
|
56
|
+
data.accounts = data.accounts.map(a => a.clientId === account.clientId ? account : a); this.store.write(data);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (terminal.has(error.oauthCode)) { data.accounts = data.accounts.map(a => a.clientId === account.clientId ? strip(a) : a); this.store.write(data); }
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!account.scopes.includes('chatgpt.tokens.use.direct')) throw new LlmError('ChatGPT plan usage is not enabled. Run /openai consent or choose /openai api-key', 'AUTH');
|
|
63
|
+
return { mode: 'chatgpt', token: account.access, key: account.clientId + ':' + account.subject };
|
|
64
|
+
}, signal);
|
|
65
|
+
}
|
|
66
|
+
async logout() {
|
|
67
|
+
return this.store.locked(async data => {
|
|
68
|
+
const account = data.accounts.find(a => a.clientId === data.active); let revoked = !account?.refresh;
|
|
69
|
+
if (account?.refresh) {
|
|
70
|
+
try {
|
|
71
|
+
const config = await this.verifier.discovery();
|
|
72
|
+
const response = await this.fetch(config.revocation_endpoint, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
73
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: account.refresh, token_type_hint: 'refresh_token', client_id: account.clientId }) });
|
|
74
|
+
revoked = response.status === 200;
|
|
75
|
+
} catch { /* local sign-out still completes */ }
|
|
76
|
+
data.accounts = data.accounts.map(a => a.clientId === account.clientId ? strip(a) : a);
|
|
77
|
+
}
|
|
78
|
+
delete data.pendingRegistration; data.revision++; this.store.write(data); return revoked;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { openBrowser } from '../account/index.mjs';
|
|
2
|
+
import { transaction, callbackServer, exchangeCode } from './oauth.mjs';
|
|
3
|
+
export class OpenAILogin {
|
|
4
|
+
constructor({ auth, models, open = openBrowser, callback = callbackServer, exchange = exchangeCode, cancelRequests = () => {} }) { Object.assign(this, { auth, models, open, callback, exchange, cancelRequests }); }
|
|
5
|
+
status() {
|
|
6
|
+
const s = this.auth.status();
|
|
7
|
+
return [`Billing: ${s.mode === 'api-key' ? 'OpenAI API key' : 'ChatGPT plan'}.`, this.attempt ? this.attempt.phase === 'exchange' ? 'Browser authorization received; exchanging and validating credentials.' : 'Waiting for browser sign-in; /openai cancel to stop.' : this.outcome,
|
|
8
|
+
s.mode === 'chatgpt' ? s.configured ? `Signed in: ${s.account.email ?? 'ChatGPT account'} [${s.account.clientId}].` : 'ChatGPT plan access is not active. Use /openai login or /openai consent.' : 'Configure the key with /login openai or OPENAI_API_KEY.',
|
|
9
|
+
'Usage and app permissions: https://chatgpt.com/settings/usage'].filter(Boolean).join('\n');
|
|
10
|
+
}
|
|
11
|
+
async login(arg) {
|
|
12
|
+
if (this.attempt) return this.status();
|
|
13
|
+
// Claim the attempt before asynchronous storage work so repeated commands
|
|
14
|
+
// cannot open two browsers with different PKCE verifiers.
|
|
15
|
+
const attempt = { controller: new AbortController() }; this.attempt = attempt; this.outcome = undefined;
|
|
16
|
+
try {
|
|
17
|
+
const data = await this.auth.store.initialize();
|
|
18
|
+
if (this.attempt !== attempt) return this.status();
|
|
19
|
+
const pending = data.pendingRegistration?.revision === data.revision ? data.pendingRegistration : undefined;
|
|
20
|
+
const account = arg === 'new' ? undefined : pending ?? data.accounts.find(a => a.clientId === data.active);
|
|
21
|
+
const tx = transaction(data, account, arg === 'consent'); attempt.tx = tx;
|
|
22
|
+
attempt.listener = await this.callback(tx, { signal: attempt.controller.signal });
|
|
23
|
+
if (this.attempt !== attempt) { attempt.listener.close(); return this.status(); }
|
|
24
|
+
attempt.done = this.follow(attempt);
|
|
25
|
+
await this.open(attempt.listener.url).catch(() => false);
|
|
26
|
+
return 'Continue with ChatGPT in your browser. Waiting up to 5 minutes; /openai status checks the result.\n' + attempt.listener.url;
|
|
27
|
+
} catch { if (this.attempt === attempt) this.attempt = undefined; throw Error('Could not start the OpenAI loopback login'); }
|
|
28
|
+
}
|
|
29
|
+
async follow(attempt) {
|
|
30
|
+
try {
|
|
31
|
+
const callback = await attempt.listener.result;
|
|
32
|
+
if (this.attempt !== attempt || attempt.controller.signal.aborted) return;
|
|
33
|
+
attempt.phase = 'exchange';
|
|
34
|
+
await this.auth.rememberRegistration(callback.clientId, attempt.tx, attempt.controller.signal);
|
|
35
|
+
const account = await this.exchange(callback, attempt.tx, this.auth.verifier, this.auth.fetch, attempt.controller.signal);
|
|
36
|
+
if (this.attempt !== attempt || attempt.controller.signal.aborted) return;
|
|
37
|
+
if (await this.auth.save(account, attempt.tx, attempt.controller.signal)) {
|
|
38
|
+
this.cancelRequests();
|
|
39
|
+
this.models.clear(); this.outcome = account.scopes.includes('chatgpt.tokens.use.direct') ? 'ChatGPT sign-in completed. Use /provider openai.' : 'Signed in, but plan usage was not granted. Use /openai consent or /openai api-key.';
|
|
40
|
+
} else this.outcome = 'Account selection changed during sign-in; retry /openai login.';
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (this.attempt === attempt) this.outcome = 'OpenAI sign-in failed. ' + (error.code ? error.message : 'Retry /openai login.')
|
|
43
|
+
+ (error.oauthCode === 'invalid_grant' ? '\nAuthorization code rejected. Run /openai login again for a fresh code; the issued client registration is retained. Do not reuse the previous browser URL.' : '');
|
|
44
|
+
}
|
|
45
|
+
finally { attempt.listener.close(); if (this.attempt === attempt) this.attempt = undefined; }
|
|
46
|
+
}
|
|
47
|
+
cancel() { this.attempt?.controller.abort(); this.attempt?.listener?.close(); this.attempt = undefined; this.outcome = 'OpenAI sign-in cancelled.'; }
|
|
48
|
+
dispose() { this.cancel(); }
|
|
49
|
+
}
|
|
50
|
+
export function openaiCommand(login) {
|
|
51
|
+
return { name: 'openai', description: 'ChatGPT account sign-in and OpenAI billing selection', input: { hint: '[login [new]|status|accounts|use <id>|consent|api-key|models|logout|cancel]' }, handler: async ({ rawInput }) => {
|
|
52
|
+
const [action = 'status', arg] = String(rawInput ?? '').trim().split(/\s+/).filter(Boolean);
|
|
53
|
+
try {
|
|
54
|
+
let text;
|
|
55
|
+
if (action === 'login' || action === 'consent') text = await login.login(action === 'consent' ? 'consent' : arg);
|
|
56
|
+
else if (action === 'status') text = login.status();
|
|
57
|
+
else if (action === 'accounts') text = login.auth.status().accounts.map(a => `${a.clientId}: ${a.email ?? 'ChatGPT account'}${a.access ? '' : ' (signed out)'}`).join('\n') || 'No saved registrations. /openai login';
|
|
58
|
+
else if (action === 'cancel') { login.cancel(); text = login.outcome; }
|
|
59
|
+
else if (action === 'logout') { login.cancel(); login.cancelRequests(); const revoked = await login.auth.logout(); login.models.clear(); text = 'Local ChatGPT tokens removed; the registration is retained for future sign-in.' + (revoked ? '' : '\nRemote revocation was not confirmed. Disconnect DSCODE at https://chatgpt.com/settings/usage'); }
|
|
60
|
+
else if (action === 'api-key' || action === 'use' && arg) { login.cancel(); login.cancelRequests(); await login.auth.select(action === 'api-key' ? 'api-key' : 'chatgpt', arg); login.models.clear(); text = login.status(); }
|
|
61
|
+
else if (action === 'models') { const models = await login.models.list(true); text = [login.models.discovered ? 'OpenAI account model catalog' : 'Fallback catalog; access is unverified.', login.models.error, ...models.map(m => `${m.id}: ${m.tiers.map(t => t.id).join('/')} speed; ${m.contextWindow} context; auto-compact at 272000 tokens or earlier for smaller models`)].filter(Boolean).join('\n'); }
|
|
62
|
+
else return { kind: 'error', text: 'Usage: /openai [login [new]|status|accounts|use <id>|consent|api-key|models|logout|cancel]' };
|
|
63
|
+
return { kind: 'success', text };
|
|
64
|
+
} catch (error) { return { kind: 'error', text: error.code ? error.message : 'OpenAI operation failed. Check account selection and retry.' }; }
|
|
65
|
+
} };
|
|
66
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
export const API = 'https://api.openai.com/v1';
|
|
3
|
+
export const ISSUER = 'https://auth.openai.com';
|
|
4
|
+
export const TOKEN_URL = ISSUER + '/api/accounts/oauth/token';
|
|
5
|
+
export const REQUEST_MS = 15000;
|
|
6
|
+
const safe = value => typeof value === 'string' && /^[a-zA-Z0-9_.:/-]{1,160}$/.test(value) ? value : undefined;
|
|
7
|
+
export function responseError(status, data, requestId) {
|
|
8
|
+
const code = safe(data?.error?.code ?? (typeof data?.error === 'string' ? data.error : undefined));
|
|
9
|
+
const param = safe(data?.error?.param);
|
|
10
|
+
const category = code === 'subscription_sharing_usage_limit_exceeded' ? 'QUOTA'
|
|
11
|
+
: code?.includes('unsupported') ? 'INVALID_REQUEST'
|
|
12
|
+
: status === 401 || status === 403 ? 'AUTH' : status === 429 ? 'RATE_LIMIT' : status >= 500 ? 'SERVER' : 'INVALID_REQUEST';
|
|
13
|
+
const error = new LlmError(`OpenAI HTTP ${status}${code ? ': ' + code : ''}${param ? ' (' + param + ')' : ''}${safe(requestId) ? ' [request ' + requestId + ']' : ''}`, category);
|
|
14
|
+
error.oauthCode = code; error.status = status; return error;
|
|
15
|
+
}
|
|
16
|
+
export async function jsonRequest(url, init = {}, fetcher = globalThis.fetch) {
|
|
17
|
+
const signal = AbortSignal.any([AbortSignal.timeout(REQUEST_MS), ...(init.signal ? [init.signal] : [])]);
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetcher(url, { ...init, redirect: 'error', signal });
|
|
20
|
+
let data; try { data = await response.json(); } catch { /* HTTP category still applies */ }
|
|
21
|
+
if (!response.ok) throw responseError(response.status, data, response.headers.get('x-request-id'));
|
|
22
|
+
if (!data || typeof data !== 'object') throw new LlmError('OpenAI returned invalid JSON', 'MALFORMED_RESPONSE');
|
|
23
|
+
return data;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof LlmError) throw error;
|
|
26
|
+
throw new LlmError(init.signal?.aborted ? 'OpenAI request cancelled' : signal.aborted ? 'OpenAI request timed out' : 'Could not contact OpenAI', init.signal?.aborted ? 'ABORTED' : signal.aborted ? 'TIMEOUT' : 'TRANSPORT');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const tokenRequest = (body, fetcher, signal) => jsonRequest(TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ ...body, resource: API }), signal }, fetcher);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { registerStartupCommand } from '../providers/startup-commands.mjs';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
5
|
+
import { RetryPolicySchema, resolveRetryPolicy, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm';
|
|
6
|
+
import { plainConfig } from '../cordis-config/plain.mjs';
|
|
7
|
+
import { currentCharge } from '../session-metrics/attribution.mjs';
|
|
8
|
+
import { OpenAIStore } from './store.mjs';
|
|
9
|
+
import { IdentityVerifier } from './oauth.mjs';
|
|
10
|
+
import { OpenAIAuth } from './auth.mjs';
|
|
11
|
+
import { OpenAIModels } from './models.mjs';
|
|
12
|
+
import { OpenAIAdapter } from './adapter.mjs';
|
|
13
|
+
import { OpenAILogin, openaiCommand } from './command.mjs';
|
|
14
|
+
import { speedCommand, selectedSpeed, speedState, canonicalSpeed, setSpeedSource } from './speed.mjs';
|
|
15
|
+
export const name = 'dscode-openai';
|
|
16
|
+
export const inject = ['llm','credentials'];
|
|
17
|
+
export const Config = z.object({
|
|
18
|
+
apiKeyEnv: z.union(['OPENAI_API_KEY']).role('credential-ref').default('OPENAI_API_KEY').volatile(),
|
|
19
|
+
serviceTier: z.union(['default','fast','ultrafast']).default('default').volatile(),
|
|
20
|
+
headerTimeoutMs: z.number().min(1).default(30000).volatile(),
|
|
21
|
+
streamIdleTimeoutMs: z.number().min(1).default(120000).volatile(),
|
|
22
|
+
retryPolicy: RetryPolicySchema.volatile(),
|
|
23
|
+
});
|
|
24
|
+
export function apply(ctx, config = {}) {
|
|
25
|
+
ctx.inject(['settings'], scoped => scoped.effect(() => scoped.settings.configure({ auto: false }, ctx.fiber)));
|
|
26
|
+
const options = () => { const value = plainConfig(config); return { serviceTier: value.serviceTier ?? 'default', headerTimeoutMs: value.headerTimeoutMs ?? 30000, streamIdleTimeoutMs: value.streamIdleTimeoutMs ?? 120000, retryPolicy: resolveRetryPolicy(value.retryPolicy, name) }; };
|
|
27
|
+
const directory = ctx.credentials.desktop ? resolveDshHome() : dirname(ctx.credentials.sharedConfig?.path ?? join(resolveDshHome(), 'credentials.yaml'));
|
|
28
|
+
const store = new OpenAIStore(join(directory, 'openai'));
|
|
29
|
+
const service = { fetch: (...args) => globalThis.fetch(...args), options };
|
|
30
|
+
const requests = new Set();
|
|
31
|
+
const cancelRequests = () => { for (const controller of requests) controller.abort(); };
|
|
32
|
+
ctx.effect(() => cancelRequests);
|
|
33
|
+
const fetcher = (...args) => service.fetch(...args);
|
|
34
|
+
service.auth = new OpenAIAuth({ store, apiKey: async () => (await ctx.credentials.resolve('OPENAI_API_KEY'))?.value, verifier: new IdentityVerifier(fetcher), fetch: fetcher });
|
|
35
|
+
service.models = new OpenAIModels(service.auth, fetcher);
|
|
36
|
+
service.login = new OpenAILogin({ auth: service.auth, models: service.models, cancelRequests });
|
|
37
|
+
const session = options => ctx.get('agents')?.get(options.sessionId ?? currentCharge()?.sessionId)?.session;
|
|
38
|
+
const adapter = new OpenAIAdapter({ auth: service.auth, models: service.models, options, fetch: fetcher,
|
|
39
|
+
registerRequest: controller => { requests.add(controller); return () => requests.delete(controller); },
|
|
40
|
+
speed: request => selectedSpeed(session(request), options().serviceTier),
|
|
41
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
42
|
+
resolveImageAccess: ref => { const attachments = ctx.get('attachments'); return attachments && resolveImageAttachmentAccess(attachments, path => ctx.get('fs')?.processPathFromHostPath(path), ref); },
|
|
43
|
+
});
|
|
44
|
+
ctx.provide('dscodeOpenAI', service);
|
|
45
|
+
ctx.effect(() => () => service.login.dispose());
|
|
46
|
+
ctx.inject(['commands'], scoped => { registerStartupCommand(scoped, openaiCommand(service.login)); registerStartupCommand(scoped, speedCommand(service)); });
|
|
47
|
+
ctx.inject(['agents'], scoped => scoped.effect(() => setSpeedSource((id, label) => {
|
|
48
|
+
const agent = scoped.agents.get(id); if (!agent) return '';
|
|
49
|
+
const route = agent.session.requestHeader()?.config ?? agent.options;
|
|
50
|
+
if (route.provider !== 'openai' || label && !label.includes(route.model)) return '';
|
|
51
|
+
const state = speedState(agent.session, route.model, options().serviceTier), selected = canonicalSpeed(state.requested);
|
|
52
|
+
return state.actual ? selected === canonicalSpeed(state.actual) ? selected : `${selected} → ${canonicalSpeed(state.actual)}` : `${selected} (unconfirmed)`;
|
|
53
|
+
})));
|
|
54
|
+
ctx.llm.registerConfigurableProviders([{ provider: 'openai', displayName: 'OpenAI / ChatGPT', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }]);
|
|
55
|
+
const registration = ctx.llm.registerAdapter(['openai'], adapter);
|
|
56
|
+
let policy = JSON.stringify(options().retryPolicy);
|
|
57
|
+
ctx.on('loader/volatile-update', () => { const next = JSON.stringify(options().retryPolicy); if (next !== policy) { registration.replace(['openai']); policy = next; } });
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { API, jsonRequest } from './http.mjs';
|
|
3
|
+
export const PROVIDER = 'openai';
|
|
4
|
+
const speed = id => ({ id, name: id === 'default' ? 'Standard' : id === 'priority' || id === 'fast' ? 'Fast' : id === 'ultrafast' ? 'Ultrafast' : id });
|
|
5
|
+
const model = (id, name) => ({ id, name, contextWindow: 1050000, maxTokens: 128000, inputModalities: ['text','image'], efforts: ['low','medium','high','xhigh','max'], defaultEffort: 'medium', tiers: ['default','fast','ultrafast'].map(speed) });
|
|
6
|
+
export const FALLBACK_MODELS = [model('gpt-6.1-sol','GPT-6.1 Sol'), model('gpt-6-astra','GPT-6 Astra')];
|
|
7
|
+
const positive = n => Number.isSafeInteger(n) && n > 0;
|
|
8
|
+
export function parseModels(data) {
|
|
9
|
+
const rows = Array.isArray(data.models) ? data.models : Array.isArray(data.data) ? data.data : undefined;
|
|
10
|
+
if (!rows) throw new LlmError('Invalid OpenAI model catalog', 'MALFORMED_RESPONSE');
|
|
11
|
+
return rows.flatMap(raw => {
|
|
12
|
+
const id = raw.slug ?? raw.id;
|
|
13
|
+
if (typeof id !== 'string' || !/^(gpt-|o[1-9]|codex-)[a-zA-Z0-9._-]*$/.test(id) || /audio|realtime|image|transcri|tts|search|chat-latest|instruct/.test(id) || raw.visibility && raw.visibility !== 'list') return [];
|
|
14
|
+
const known = FALLBACK_MODELS.find(m => m.id === id);
|
|
15
|
+
const efforts = (raw.supported_reasoning_levels ?? raw.supported_reasoning_efforts ?? known?.efforts ?? []).map(e => typeof e === 'string' ? e : e.effort ?? e.reasoning_effort).filter(e => typeof e === 'string' && /^[a-z0-9_-]{1,64}$/.test(e));
|
|
16
|
+
const rawTiers = raw.service_tiers ?? raw.serviceTiers;
|
|
17
|
+
const tiers = Array.isArray(rawTiers) ? rawTiers.flatMap(t => {
|
|
18
|
+
const id = typeof t === 'string' ? t : t.id;
|
|
19
|
+
return typeof id === 'string' && /^[a-z0-9_-]{1,64}$/.test(id) ? [speed(id)] : [];
|
|
20
|
+
}) : known?.tiers ?? [speed('default')];
|
|
21
|
+
if (!tiers.some(t => t.id === 'default')) tiers.unshift(speed('default'));
|
|
22
|
+
return [{ id, name: raw.display_name ?? known?.name ?? id,
|
|
23
|
+
contextWindow: positive(raw.context_window) ? raw.context_window : known?.contextWindow ?? 32000,
|
|
24
|
+
maxTokens: positive(raw.max_output_tokens) ? raw.max_output_tokens : known?.maxTokens ?? 8192,
|
|
25
|
+
inputModalities: Array.isArray(raw.input_modalities) ? raw.input_modalities.filter(m => ['text','image'].includes(m)) : known?.inputModalities ?? ['text'],
|
|
26
|
+
efforts: [...new Set(efforts)], defaultEffort: raw.default_reasoning_level ?? known?.defaultEffort ?? efforts[0], tiers }];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function resolveSpeed(model, requested = 'default') {
|
|
30
|
+
const canonical = requested === 'standard' ? 'default' : requested;
|
|
31
|
+
const tier = model.tiers.find(t => t.id === canonical) ?? (canonical === 'fast' ? model.tiers.find(t => t.id === 'priority') : undefined);
|
|
32
|
+
if (!tier) throw new LlmError(`This model does not advertise ${requested} speed; use /speed to inspect available tiers`, 'UNSUPPORTED_SERVICE_TIER');
|
|
33
|
+
return tier.id;
|
|
34
|
+
}
|
|
35
|
+
export class OpenAIModels {
|
|
36
|
+
constructor(auth, fetcher = globalThis.fetch) { this.auth = auth; this.fetch = fetcher; this.clear(); }
|
|
37
|
+
clear() { this.entries = FALLBACK_MODELS; this.checked = 0; this.key = undefined; this.discovered = false; this.error = undefined; }
|
|
38
|
+
async list(force = false, credential, signal) {
|
|
39
|
+
let auth = credential;
|
|
40
|
+
try { auth ??= await this.auth.acquire(signal); }
|
|
41
|
+
catch (error) { if (signal?.aborted) throw error; this.clear(); this.error = error.message; return this.entries; }
|
|
42
|
+
if (auth.key !== this.key) { this.clear(); this.key = auth.key; }
|
|
43
|
+
if (!force && Date.now() - this.checked < 300000) return this.entries;
|
|
44
|
+
try {
|
|
45
|
+
const entries = parseModels(await jsonRequest(API + '/models', { headers: { authorization: 'Bearer ' + auth.token }, signal }, this.fetch));
|
|
46
|
+
if (this.key === auth.key) { this.entries = entries; this.discovered = true; this.error = undefined; }
|
|
47
|
+
return entries;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (signal?.aborted) throw error;
|
|
50
|
+
if (this.key === auth.key) this.error = error.message;
|
|
51
|
+
return this.key === auth.key ? this.entries : FALLBACK_MODELS;
|
|
52
|
+
} finally { if (this.key === auth.key) this.checked = Date.now(); }
|
|
53
|
+
}
|
|
54
|
+
async get(id, auth, signal) {
|
|
55
|
+
const model = (await this.list(false, auth, signal)).find(m => m.id === id);
|
|
56
|
+
if (!model) throw new LlmError('Model is not in the OpenAI account catalog; refresh with /openai models', 'MODEL_NOT_FOUND');
|
|
57
|
+
return model;
|
|
58
|
+
}
|
|
59
|
+
}
|