@giovannijecha/jecode 0.1.9 → 0.2.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/README.md +36 -18
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +1 -1
- package/dist/controller.js +46 -11
- package/dist/credential-commands.js +32 -4
- package/dist/credential-safety.js +10 -1
- package/dist/effort.js +25 -0
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +94 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/provider-commands.js +79 -38
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic-stream.js +8 -2
- package/dist/providers/anthropic.js +22 -6
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama-stream.js +3 -0
- package/dist/providers/ollama.js +4 -1
- package/dist/providers/openai-codex.js +157 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -9
- package/dist/providers/openai.js +36 -15
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +64 -9
- package/dist/settings.js +2 -1
- package/dist/tools/args.js +4 -3
- package/dist/tools/fs.js +8 -3
- package/dist/tools/index.js +2 -2
- package/dist/tools/shell.js +2 -0
- package/dist/tui/app-workflows.js +5 -0
- package/dist/tui/components/messages.js +26 -5
- package/dist/tui/feedback.js +10 -6
- package/dist/tui/session-view.js +10 -4
- package/dist/tui/turn.js +4 -1
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// OpenAI's ChatGPT OAuth protocol, implemented with Node primitives only.
|
|
2
|
+
//
|
|
3
|
+
// This module owns the authority handshake. It does not know about the TUI or
|
|
4
|
+
// persist anything; callers decide when an explicitly completed login becomes
|
|
5
|
+
// an account on disk.
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { oauthRequest } from "./oauth-http.js";
|
|
8
|
+
import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
|
|
9
|
+
import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
|
|
10
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
|
+
const AUTHORITY = "https://auth.openai.com";
|
|
12
|
+
const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
|
|
13
|
+
const TOKEN = `${AUTHORITY}/oauth/token`;
|
|
14
|
+
const REVOKE = `${AUTHORITY}/oauth/revoke`;
|
|
15
|
+
const DEVICE_CODE = `${AUTHORITY}/api/accounts/deviceauth/usercode`;
|
|
16
|
+
const DEVICE_POLL = `${AUTHORITY}/api/accounts/deviceauth/token`;
|
|
17
|
+
const DEVICE_VERIFY = `${AUTHORITY}/codex/device`;
|
|
18
|
+
const DEVICE_REDIRECT = `${AUTHORITY}/deviceauth/callback`;
|
|
19
|
+
const LOGIN_LIMIT_MS = 15 * 60_000;
|
|
20
|
+
export async function beginBrowserLogin() {
|
|
21
|
+
const verifier = randomBytes(64).toString("base64url");
|
|
22
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
23
|
+
const state = randomBytes(32).toString("base64url");
|
|
24
|
+
const callback = await openAICallback(state);
|
|
25
|
+
const redirectUri = `http://localhost:${callback.port}${OPENAI_CALLBACK_PATH}`;
|
|
26
|
+
const authorize = new URL(AUTHORIZE);
|
|
27
|
+
authorize.search = new URLSearchParams({
|
|
28
|
+
response_type: "code",
|
|
29
|
+
client_id: CLIENT_ID,
|
|
30
|
+
redirect_uri: redirectUri,
|
|
31
|
+
scope: "openid profile email offline_access",
|
|
32
|
+
code_challenge: challenge,
|
|
33
|
+
code_challenge_method: "S256",
|
|
34
|
+
state,
|
|
35
|
+
id_token_add_organizations: "true",
|
|
36
|
+
codex_cli_simplified_flow: "true",
|
|
37
|
+
originator: "jecode",
|
|
38
|
+
}).toString();
|
|
39
|
+
return {
|
|
40
|
+
url: authorize.href,
|
|
41
|
+
async complete(signal) {
|
|
42
|
+
try {
|
|
43
|
+
const code = await abortable(callback.code, signal);
|
|
44
|
+
const account = openAIAccountFromTokens(await exchange({
|
|
45
|
+
authorizationCode: code,
|
|
46
|
+
verifier,
|
|
47
|
+
redirectUri,
|
|
48
|
+
}, signal));
|
|
49
|
+
await callback.respond(true);
|
|
50
|
+
return account;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await callback.respond(false);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await callback.close();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
close: callback.close,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function beginDeviceLogin(signal) {
|
|
64
|
+
const start = await oauthRequest(DEVICE_CODE, { contentType: "application/json", value: { client_id: CLIENT_ID } }, signal);
|
|
65
|
+
const value = record(start.value) ? start.value : {};
|
|
66
|
+
const deviceAuthId = required(value["device_auth_id"], "device authorization id");
|
|
67
|
+
const code = required(value["user_code"] ?? value["usercode"], "device code");
|
|
68
|
+
const interval = intervalSeconds(value["interval"]);
|
|
69
|
+
return {
|
|
70
|
+
url: DEVICE_VERIFY,
|
|
71
|
+
code,
|
|
72
|
+
async complete(waitSignal) {
|
|
73
|
+
const combined = combine(signal, waitSignal);
|
|
74
|
+
const pending = await pollDevice(deviceAuthId, code, interval, combined);
|
|
75
|
+
return openAIAccountFromTokens(await exchange(pending, combined));
|
|
76
|
+
},
|
|
77
|
+
close: async () => { },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function refreshOpenAITokens(account, signal) {
|
|
81
|
+
const response = await oauthRequest(TOKEN, {
|
|
82
|
+
contentType: "application/json",
|
|
83
|
+
value: {
|
|
84
|
+
client_id: CLIENT_ID,
|
|
85
|
+
grant_type: "refresh_token",
|
|
86
|
+
refresh_token: account.refreshToken,
|
|
87
|
+
},
|
|
88
|
+
}, signal);
|
|
89
|
+
const token = openAITokenReply(response.value, account.refreshToken);
|
|
90
|
+
const refreshed = openAIAccountFromTokens(token);
|
|
91
|
+
if (refreshed.accountId !== account.accountId) {
|
|
92
|
+
throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...refreshed,
|
|
96
|
+
...(refreshed.email === undefined && account.email !== undefined ? { email: account.email } : {}),
|
|
97
|
+
...(refreshed.plan === undefined && account.plan !== undefined ? { plan: account.plan } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function revokeOpenAITokens(account, signal) {
|
|
101
|
+
await oauthRequest(REVOKE, {
|
|
102
|
+
contentType: "application/json",
|
|
103
|
+
value: {
|
|
104
|
+
token: account.refreshToken,
|
|
105
|
+
token_type_hint: "refresh_token",
|
|
106
|
+
client_id: CLIENT_ID,
|
|
107
|
+
},
|
|
108
|
+
}, signal);
|
|
109
|
+
}
|
|
110
|
+
async function exchange(code, signal) {
|
|
111
|
+
const response = await oauthRequest(TOKEN, {
|
|
112
|
+
contentType: "application/x-www-form-urlencoded",
|
|
113
|
+
value: new URLSearchParams({
|
|
114
|
+
grant_type: "authorization_code",
|
|
115
|
+
client_id: CLIENT_ID,
|
|
116
|
+
code: code.authorizationCode,
|
|
117
|
+
code_verifier: code.verifier,
|
|
118
|
+
redirect_uri: code.redirectUri,
|
|
119
|
+
}),
|
|
120
|
+
}, signal);
|
|
121
|
+
return openAITokenReply(response.value);
|
|
122
|
+
}
|
|
123
|
+
async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
124
|
+
const started = Date.now();
|
|
125
|
+
while (Date.now() - started < LOGIN_LIMIT_MS) {
|
|
126
|
+
const response = await oauthRequest(DEVICE_POLL, {
|
|
127
|
+
contentType: "application/json",
|
|
128
|
+
value: { device_auth_id: deviceAuthId, user_code: userCode },
|
|
129
|
+
}, signal, [200, 403, 404]);
|
|
130
|
+
if (response.status === 200) {
|
|
131
|
+
const value = record(response.value) ? response.value : {};
|
|
132
|
+
return {
|
|
133
|
+
authorizationCode: required(value["authorization_code"], "authorization code"),
|
|
134
|
+
verifier: required(value["code_verifier"], "code verifier"),
|
|
135
|
+
redirectUri: DEVICE_REDIRECT,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
await sleep(interval * 1_000, signal);
|
|
139
|
+
}
|
|
140
|
+
throw new Error("ChatGPT device sign-in timed out after 15 minutes");
|
|
141
|
+
}
|
|
142
|
+
function intervalSeconds(value) {
|
|
143
|
+
const parsed = typeof value === "string" ? Number(value.trim()) : value;
|
|
144
|
+
return typeof parsed === "number" && Number.isFinite(parsed)
|
|
145
|
+
? Math.max(1, Math.min(30, Math.floor(parsed)))
|
|
146
|
+
: 5;
|
|
147
|
+
}
|
|
148
|
+
function required(value, label) {
|
|
149
|
+
const found = optional(value);
|
|
150
|
+
if (found === undefined)
|
|
151
|
+
throw new Error(`OpenAI sign-in returned no ${label}`);
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
function optional(value) {
|
|
155
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
156
|
+
}
|
|
157
|
+
function record(value) {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
function combine(left, right) {
|
|
161
|
+
if (left === undefined)
|
|
162
|
+
return right;
|
|
163
|
+
if (right === undefined)
|
|
164
|
+
return left;
|
|
165
|
+
return AbortSignal.any([left, right]);
|
|
166
|
+
}
|
|
167
|
+
function abortable(promise, signal) {
|
|
168
|
+
if (signal === undefined)
|
|
169
|
+
return promise;
|
|
170
|
+
if (signal.aborted)
|
|
171
|
+
return Promise.reject(abortReason(signal));
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const onAbort = () => reject(abortReason(signal));
|
|
174
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
promise.then((value) => {
|
|
176
|
+
signal.removeEventListener("abort", onAbort);
|
|
177
|
+
resolve(value);
|
|
178
|
+
}, (error) => {
|
|
179
|
+
signal.removeEventListener("abort", onAbort);
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function sleep(ms, signal) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
if (signal?.aborted === true) {
|
|
187
|
+
reject(abortReason(signal));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
resolve();
|
|
193
|
+
}, ms);
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
|
|
197
|
+
};
|
|
198
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function abortReason(signal) {
|
|
202
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
203
|
+
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { PROVIDERS } from "./providers/index.js";
|
|
4
4
|
import { readSettings } from "./settings.js";
|
|
5
|
-
import {
|
|
5
|
+
import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
|
|
6
6
|
import { providerFailure } from "./provider-errors.js";
|
|
7
|
+
import { providerLabel } from "./provider-label.js";
|
|
8
|
+
import { compatibleEffort } from "./effort.js";
|
|
7
9
|
/**
|
|
8
10
|
* The provider menu.
|
|
9
11
|
*
|
|
@@ -34,32 +36,22 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
34
36
|
// Picking the provider already in use is not a no-op when it cannot run:
|
|
35
37
|
// it is how the user asks to fix the reason it cannot.
|
|
36
38
|
if (chosen.id === session.provider.id) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
await askForKey(chosen.keyVar, host, session.palette);
|
|
44
|
-
if (chosen.blocked() !== undefined)
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
return true;
|
|
39
|
+
if (!(await ensureProviderAuthentication(chosen, session, host)))
|
|
40
|
+
return false;
|
|
41
|
+
return session.model === ""
|
|
42
|
+
? modelsCommand(session, host, { announce: false, save: behavior.save })
|
|
43
|
+
: true;
|
|
48
44
|
}
|
|
49
45
|
// A provider that cannot run is worth one offer to fix it, here, rather
|
|
50
46
|
// than a note telling the user to leave and export something.
|
|
51
47
|
const blocked = chosen.blocked();
|
|
52
48
|
if (blocked !== undefined) {
|
|
53
|
-
|
|
54
|
-
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
await askForKey(chosen.keyVar, host, session.palette);
|
|
49
|
+
await ensureProviderAuthentication(chosen, session, host);
|
|
58
50
|
const still = chosen.blocked();
|
|
59
51
|
if (still !== undefined) {
|
|
60
52
|
host.emit({
|
|
61
53
|
kind: "notice",
|
|
62
|
-
text: `${
|
|
54
|
+
text: `${providerLabel(chosen.id)} still needs ${authenticationNeed(chosen)} · provider unchanged`,
|
|
63
55
|
tone: "warn",
|
|
64
56
|
});
|
|
65
57
|
return false;
|
|
@@ -70,6 +62,7 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
70
62
|
model: session.model,
|
|
71
63
|
providerId: session.config.providerId,
|
|
72
64
|
configModel: session.config.model,
|
|
65
|
+
effort: session.config.effort,
|
|
73
66
|
};
|
|
74
67
|
session.provider = chosen;
|
|
75
68
|
// The model belonged to the old provider. Carrying it across would send
|
|
@@ -77,16 +70,31 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
77
70
|
session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
|
|
78
71
|
session.config.providerId = chosen.id;
|
|
79
72
|
session.config.model = session.model;
|
|
73
|
+
if (session.model === "") {
|
|
74
|
+
if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
|
|
75
|
+
restoreProvider(session, before);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const alignment = await alignEffort(session, host);
|
|
81
|
+
if (!alignment.ok) {
|
|
82
|
+
restoreProvider(session, before);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
80
86
|
if (behavior.save !== false) {
|
|
81
87
|
const saved = readSettings();
|
|
82
88
|
const models = { ...saved.models };
|
|
83
89
|
if (session.model !== "")
|
|
84
90
|
models[chosen.id] = session.model;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
session.config.
|
|
89
|
-
|
|
91
|
+
const patch = {
|
|
92
|
+
provider: chosen.id,
|
|
93
|
+
models,
|
|
94
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
95
|
+
};
|
|
96
|
+
if (!(await saveDefaults(host, patch))) {
|
|
97
|
+
restoreProvider(session, before);
|
|
90
98
|
return false;
|
|
91
99
|
}
|
|
92
100
|
}
|
|
@@ -111,16 +119,12 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
111
119
|
// to pick a model, and "go and export a variable" is not an answer.
|
|
112
120
|
const blocked = provider.blocked();
|
|
113
121
|
if (blocked !== undefined) {
|
|
114
|
-
|
|
115
|
-
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
await askForKey(provider.keyVar, host, session.palette);
|
|
122
|
+
await ensureProviderAuthentication(provider, session, host);
|
|
119
123
|
const still = provider.blocked();
|
|
120
124
|
if (still !== undefined) {
|
|
121
125
|
host.emit({
|
|
122
126
|
kind: "notice",
|
|
123
|
-
text: `${
|
|
127
|
+
text: `${providerLabel(provider.id)} still needs ${authenticationNeed(provider)}`,
|
|
124
128
|
tone: "warn",
|
|
125
129
|
});
|
|
126
130
|
return false;
|
|
@@ -158,15 +162,31 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
158
162
|
const chosen = ids[index];
|
|
159
163
|
if (chosen === undefined)
|
|
160
164
|
return false;
|
|
161
|
-
const before = {
|
|
165
|
+
const before = {
|
|
166
|
+
model: session.model,
|
|
167
|
+
configModel: session.config.model,
|
|
168
|
+
effort: session.config.effort,
|
|
169
|
+
};
|
|
162
170
|
session.model = chosen;
|
|
163
171
|
session.config.model = chosen;
|
|
172
|
+
const alignment = await alignEffort(session, host);
|
|
173
|
+
if (!alignment.ok) {
|
|
174
|
+
session.model = before.model;
|
|
175
|
+
session.config.model = before.configModel;
|
|
176
|
+
session.config.effort = before.effort;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
164
179
|
if (behavior.save !== false) {
|
|
165
180
|
const saved = readSettings();
|
|
166
181
|
const models = { ...saved.models, [provider.id]: chosen };
|
|
167
|
-
|
|
182
|
+
const patch = {
|
|
183
|
+
models,
|
|
184
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
185
|
+
};
|
|
186
|
+
if (!(await saveDefaults(host, patch))) {
|
|
168
187
|
session.model = before.model;
|
|
169
188
|
session.config.model = before.configModel;
|
|
189
|
+
session.config.effort = before.effort;
|
|
170
190
|
return false;
|
|
171
191
|
}
|
|
172
192
|
}
|
|
@@ -175,19 +195,40 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
175
195
|
}
|
|
176
196
|
return true;
|
|
177
197
|
}
|
|
178
|
-
|
|
198
|
+
function restoreProvider(session, before) {
|
|
199
|
+
session.provider = before.provider;
|
|
200
|
+
session.model = before.model;
|
|
201
|
+
session.config.providerId = before.providerId;
|
|
202
|
+
session.config.model = before.configModel;
|
|
203
|
+
session.config.effort = before.effort;
|
|
204
|
+
}
|
|
205
|
+
async function alignEffort(session, host) {
|
|
206
|
+
if (session.provider.efforts === undefined)
|
|
207
|
+
return { ok: true };
|
|
208
|
+
let supported;
|
|
209
|
+
try {
|
|
210
|
+
supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
host.emit({
|
|
214
|
+
kind: "notice",
|
|
215
|
+
text: providerFailure(session.provider, error, true),
|
|
216
|
+
tone: "error",
|
|
217
|
+
});
|
|
218
|
+
return { ok: false };
|
|
219
|
+
}
|
|
220
|
+
const adjusted = compatibleEffort(session.config.effort, supported);
|
|
221
|
+
if (adjusted === undefined || adjusted === session.config.effort)
|
|
222
|
+
return { ok: true };
|
|
223
|
+
session.config.effort = adjusted;
|
|
224
|
+
return { ok: true, adjusted };
|
|
225
|
+
}
|
|
179
226
|
function chooser(host) {
|
|
180
227
|
if (host.choose === undefined) {
|
|
181
228
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
182
229
|
}
|
|
183
230
|
return host.choose;
|
|
184
231
|
}
|
|
185
|
-
function providerName(id) {
|
|
186
|
-
return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
187
|
-
}
|
|
188
|
-
function isCredentialBlocker(provider, blocked) {
|
|
189
|
-
return blocked.startsWith(`${provider.keyVar} `);
|
|
190
|
-
}
|
|
191
232
|
async function saveDefaults(host, patch) {
|
|
192
233
|
if (host.saveSettings === undefined)
|
|
193
234
|
return true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Stable human-facing names for provider identifiers.
|
|
2
|
+
export function providerLabel(id) {
|
|
3
|
+
switch (id) {
|
|
4
|
+
case "anthropic": return "Anthropic";
|
|
5
|
+
case "openai": return "OpenAI";
|
|
6
|
+
case "openai-codex": return "OpenAI Codex";
|
|
7
|
+
case "ollama": return "Ollama";
|
|
8
|
+
default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -13,7 +13,8 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
13
13
|
let stopReason;
|
|
14
14
|
let stopDetails;
|
|
15
15
|
let usage;
|
|
16
|
-
|
|
16
|
+
let complete = false;
|
|
17
|
+
stream: for await (const raw of events) {
|
|
17
18
|
const event = raw;
|
|
18
19
|
switch (event.type) {
|
|
19
20
|
case "message_start":
|
|
@@ -51,10 +52,15 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
51
52
|
case "error": {
|
|
52
53
|
throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
|
|
53
54
|
}
|
|
55
|
+
case "message_stop":
|
|
56
|
+
complete = true;
|
|
57
|
+
break stream;
|
|
54
58
|
default:
|
|
55
|
-
break; //
|
|
59
|
+
break; // ping and unknown forward-compatible events
|
|
56
60
|
}
|
|
57
61
|
}
|
|
62
|
+
if (!complete)
|
|
63
|
+
throw new Error("anthropic stream ended before message_stop");
|
|
58
64
|
const content = [...blocks.entries()]
|
|
59
65
|
.sort(([a], [b]) => a - b)
|
|
60
66
|
.map(([, block]) => block);
|
|
@@ -9,25 +9,33 @@
|
|
|
9
9
|
import { postSse } from "./http.js";
|
|
10
10
|
import { listModels } from "./catalog.js";
|
|
11
11
|
import { keyFor } from "../credentials.js";
|
|
12
|
+
import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
12
13
|
import { assembleAnthropic } from "./anthropic-stream.js";
|
|
13
14
|
import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
|
|
14
15
|
const ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
15
16
|
const MODELS = "https://api.anthropic.com/v1/models?limit=100";
|
|
16
17
|
const API_VERSION = "2023-06-01";
|
|
17
18
|
const KEY = "ANTHROPIC_API_KEY";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
|
|
19
|
+
const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
|
|
20
|
+
const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
|
|
21
|
+
const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
|
|
22
22
|
export function supportsAdaptiveThinking(model) {
|
|
23
23
|
return ADAPTIVE.test(model);
|
|
24
24
|
}
|
|
25
|
+
export function anthropicEfforts(model) {
|
|
26
|
+
if (/^claude-(?:(?:opus|sonnet)-4-6|mythos-preview)(?:-|$)/.test(model)) {
|
|
27
|
+
return MAX_WITHOUT_XHIGH;
|
|
28
|
+
}
|
|
29
|
+
if (/^claude-opus-4-5(?:-|$)/.test(model))
|
|
30
|
+
return ANTHROPIC_45_EFFORTS;
|
|
31
|
+
return supportsAdaptiveThinking(model) ? EFFORTS : [];
|
|
32
|
+
}
|
|
25
33
|
export const anthropic = {
|
|
26
34
|
id: "anthropic",
|
|
27
35
|
// Sonnet is the default because it is the one that can be left running.
|
|
28
36
|
// Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
|
|
29
37
|
defaultModel: "claude-sonnet-5",
|
|
30
|
-
keyVar: KEY,
|
|
38
|
+
auth: { kind: "api-key", keyVar: KEY },
|
|
31
39
|
blocked() {
|
|
32
40
|
return apiKey() === undefined ? `${KEY} is not set` : undefined;
|
|
33
41
|
},
|
|
@@ -36,6 +44,9 @@ export const anthropic = {
|
|
|
36
44
|
models(signal, onStatus) {
|
|
37
45
|
return listModels(MODELS, headers(requireKey()), signal, onStatus);
|
|
38
46
|
},
|
|
47
|
+
async efforts(model) {
|
|
48
|
+
return anthropicEfforts(model);
|
|
49
|
+
},
|
|
39
50
|
location: () => "cloud",
|
|
40
51
|
async send(req) {
|
|
41
52
|
const key = requireKey();
|
|
@@ -49,7 +60,12 @@ export const anthropic = {
|
|
|
49
60
|
};
|
|
50
61
|
if (supportsAdaptiveThinking(req.model)) {
|
|
51
62
|
body["thinking"] = { type: "adaptive", display: "summarized" };
|
|
52
|
-
|
|
63
|
+
}
|
|
64
|
+
const efforts = anthropicEfforts(req.model);
|
|
65
|
+
if (efforts.length > 0) {
|
|
66
|
+
body["output_config"] = {
|
|
67
|
+
effort: requireSupportedEffort(req.model, req.effort, efforts),
|
|
68
|
+
};
|
|
53
69
|
}
|
|
54
70
|
const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
|
|
55
71
|
const data = await assembleAnthropic(events, req.onStream);
|
package/dist/providers/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { anthropic } from "./anthropic.js";
|
|
2
2
|
import { openai } from "./openai.js";
|
|
3
|
+
import { openaiCodex } from "./openai-codex.js";
|
|
3
4
|
import { configureOllama, ollama } from "./ollama.js";
|
|
4
|
-
export const PROVIDERS = [anthropic, openai, ollama];
|
|
5
|
+
export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
|
|
5
6
|
export function providerNames() {
|
|
6
7
|
return PROVIDERS.map((provider) => provider.id);
|
|
7
8
|
}
|
|
@@ -51,6 +51,9 @@ export async function assembleOllama(events, onStream) {
|
|
|
51
51
|
calls.set(index, call);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
if (finishReason === undefined) {
|
|
55
|
+
throw new Error("ollama stream ended before a finish reason");
|
|
56
|
+
}
|
|
54
57
|
const toolCalls = [...calls.entries()]
|
|
55
58
|
.sort(([a], [b]) => a - b)
|
|
56
59
|
// Not every server sends an id, and the loop needs one to pair the result
|
package/dist/providers/ollama.js
CHANGED
|
@@ -25,7 +25,7 @@ export function ollamaConnection() {
|
|
|
25
25
|
export const ollama = {
|
|
26
26
|
id: "ollama",
|
|
27
27
|
defaultModel: "",
|
|
28
|
-
keyVar: KEY,
|
|
28
|
+
auth: { kind: "api-key", keyVar: KEY },
|
|
29
29
|
// The only provider whose key is conditional: a daemon on this machine is
|
|
30
30
|
// reached over loopback and asks for nothing, so demanding a key there
|
|
31
31
|
// would be an invented requirement.
|
|
@@ -45,6 +45,9 @@ export const ollama = {
|
|
|
45
45
|
const at = endpoint();
|
|
46
46
|
return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
|
|
47
47
|
},
|
|
48
|
+
async efforts() {
|
|
49
|
+
return [];
|
|
50
|
+
},
|
|
48
51
|
location: () => {
|
|
49
52
|
try {
|
|
50
53
|
return endpoint().loopback ? "local" : "cloud";
|