@xandreed/plugin-models 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +90 -0
- package/dist/auth/anthropicOAuth.test.d.ts +1 -0
- package/dist/auth/localAuth.d.ts +8 -0
- package/dist/auth/localAuth.js +90 -0
- package/dist/auth/localAuth.test.d.ts +1 -0
- package/dist/auth/openAiCodexOAuth.d.ts +25 -0
- package/dist/auth/openAiCodexOAuth.js +58 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +53 -0
- package/dist/llm/compat.d.ts +77 -0
- package/dist/llm/compat.js +215 -0
- package/dist/llm/compat.test.d.ts +1 -0
- package/dist/llm/modelCatalog.d.ts +10 -0
- package/dist/llm/modelCatalog.js +58 -0
- package/dist/llm/modelCatalog.test.d.ts +1 -0
- package/dist/llm/openAiCodex.d.ts +18 -0
- package/dist/llm/openAiCodex.js +99 -0
- package/dist/llm/openAiCodexWebSocket.d.ts +14 -0
- package/dist/llm/openAiCodexWebSocket.js +126 -0
- package/dist/llm/providers.d.ts +37 -0
- package/dist/llm/providers.js +114 -0
- package/dist/llm/providers.test.d.ts +1 -0
- package/dist/llm/retry.d.ts +54 -0
- package/dist/llm/retry.js +61 -0
- package/dist/llm/retry.test.d.ts +1 -0
- package/dist/llm/router.d.ts +55 -0
- package/dist/llm/router.js +125 -0
- package/dist/llm/router.test.d.ts +1 -0
- package/dist/llm/sse.d.ts +45 -0
- package/dist/llm/sse.js +116 -0
- package/dist/llm/sse.test.d.ts +1 -0
- package/dist/llm/utilityLlm.d.ts +8 -0
- package/dist/llm/utilityLlm.js +33 -0
- package/dist/plugin.adapter.d.ts +2 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/settings/localSettings.d.ts +3 -0
- package/dist/settings/localSettings.js +72 -0
- package/dist/settings/localSettings.test.d.ts +1 -0
- package/dist/settings/roleView.d.ts +11 -0
- package/dist/settings/roleView.js +11 -0
- package/dist/settings/roleView.test.d.ts +1 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xand Reed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { AuthError } from "@xandreed/core";
|
|
3
|
+
/**
|
|
4
|
+
* Anthropic OAuth (Claude Pro/Max subscription) — the full protocol:
|
|
5
|
+
* authorization-code + PKCE (begin/exchange) and refresh. Constants match
|
|
6
|
+
* the public Claude Code OAuth client (the client id is not a secret).
|
|
7
|
+
* These are pure protocol helpers; the loopback callback server and the
|
|
8
|
+
* browser-open live in the TUI driver.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
11
|
+
export declare const ANTHROPIC_CALLBACK_PORT = 53692;
|
|
12
|
+
export declare const ANTHROPIC_CALLBACK_PATH = "/callback";
|
|
13
|
+
export declare const ANTHROPIC_REDIRECT_URI = "http://localhost:53692/callback";
|
|
14
|
+
/** Beta flags the subscription path requires (Claude Code identity + oauth). */
|
|
15
|
+
export declare const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
|
|
16
|
+
/** Anthropic rejects OAuth tokens unless the first system block is exactly this. */
|
|
17
|
+
export declare const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
18
|
+
export interface RefreshedTokens {
|
|
19
|
+
readonly access: string;
|
|
20
|
+
readonly refresh: string;
|
|
21
|
+
/** Absolute expiry, epoch ms (with a 5-minute early-refresh skew baked in). */
|
|
22
|
+
readonly expires: number;
|
|
23
|
+
}
|
|
24
|
+
/** Refresh an expired (or near-expiry) access token. */
|
|
25
|
+
export declare const refreshAnthropicToken: (refreshToken: string) => Effect.Effect<RefreshedTokens, AuthError>;
|
|
26
|
+
export interface Pkce {
|
|
27
|
+
readonly verifier: string;
|
|
28
|
+
readonly challenge: string;
|
|
29
|
+
}
|
|
30
|
+
/** Generate a PKCE verifier + S256 challenge (Web Crypto; Bun-global). */
|
|
31
|
+
export declare const generatePkce: Effect.Effect<Pkce>;
|
|
32
|
+
export interface OAuthBegin {
|
|
33
|
+
/** The PKCE verifier — ALSO the `state`; the driver must verify the
|
|
34
|
+
* redirect echoes it (CSRF) before exchanging. */
|
|
35
|
+
readonly verifier: string;
|
|
36
|
+
readonly authorizeUrl: string;
|
|
37
|
+
readonly callbackPort: number;
|
|
38
|
+
readonly callbackPath: string;
|
|
39
|
+
}
|
|
40
|
+
/** Start an authorization: PKCE material + the browser URL. */
|
|
41
|
+
export declare const beginAnthropicOAuth: Effect.Effect<OAuthBegin>;
|
|
42
|
+
/** Exchange the authorization code for tokens. */
|
|
43
|
+
export declare const exchangeAnthropicCode: (code: string, verifier: string) => Effect.Effect<RefreshedTokens, AuthError>;
|
|
44
|
+
export interface AuthRedirect {
|
|
45
|
+
readonly code: Option.Option<string>;
|
|
46
|
+
readonly state: Option.Option<string>;
|
|
47
|
+
}
|
|
48
|
+
/** Pull `code`/`state` out of a pasted redirect URL, `code#state` pair,
|
|
49
|
+
* bare query string, or raw code. Total — garbage is `{none, none}`. */
|
|
50
|
+
export declare const parseAuthorizationInput: (input: string) => AuthRedirect;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { AuthError } from "@xandreed/core";
|
|
3
|
+
export const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
4
|
+
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize", TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
5
|
+
export const ANTHROPIC_CALLBACK_PORT = 53692, ANTHROPIC_CALLBACK_PATH = "/callback", ANTHROPIC_REDIRECT_URI = `http://localhost:${ANTHROPIC_CALLBACK_PORT}${ANTHROPIC_CALLBACK_PATH}`;
|
|
6
|
+
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
7
|
+
export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20", CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
8
|
+
const oauthError = (stage) => (detail) => new AuthError({
|
|
9
|
+
provider: "anthropic",
|
|
10
|
+
message: `OAuth ${stage} failed: ${detail}`
|
|
11
|
+
}), postToken = (body, stage) => {
|
|
12
|
+
const fail = oauthError(stage);
|
|
13
|
+
return Effect.tryPromise({
|
|
14
|
+
try: async () => {
|
|
15
|
+
const res = await fetch(TOKEN_URL, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
signal: AbortSignal.timeout(30000)
|
|
20
|
+
});
|
|
21
|
+
return { ok: res.ok, status: res.status, text: await res.text() };
|
|
22
|
+
},
|
|
23
|
+
catch: (e) => fail(String(e))
|
|
24
|
+
}).pipe(Effect.filterOrFail((res) => res.ok, (res) => fail(`status ${res.status}: ${res.text.slice(0, 200)}`)), Effect.flatMap((res) => Effect.try({
|
|
25
|
+
try: () => JSON.parse(res.text),
|
|
26
|
+
catch: () => fail("the token response was not JSON")
|
|
27
|
+
})), Effect.map((data) => ({
|
|
28
|
+
access: data.access_token,
|
|
29
|
+
refresh: data.refresh_token,
|
|
30
|
+
expires: Date.now() + data.expires_in * 1000 - 300000
|
|
31
|
+
})));
|
|
32
|
+
};
|
|
33
|
+
export const refreshAnthropicToken = (refreshToken) => postToken({
|
|
34
|
+
grant_type: "refresh_token",
|
|
35
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
36
|
+
refresh_token: refreshToken
|
|
37
|
+
}, "token refresh");
|
|
38
|
+
const base64url = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
39
|
+
export const generatePkce = Effect.promise(async () => {
|
|
40
|
+
const verifierBytes = new Uint8Array(32);
|
|
41
|
+
crypto.getRandomValues(verifierBytes);
|
|
42
|
+
const verifier = base64url(verifierBytes), digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
43
|
+
return { verifier, challenge: base64url(new Uint8Array(digest)) };
|
|
44
|
+
}), beginAnthropicOAuth = Effect.map(generatePkce, (pkce) => ({
|
|
45
|
+
verifier: pkce.verifier,
|
|
46
|
+
authorizeUrl: `${AUTHORIZE_URL}?${new URLSearchParams({
|
|
47
|
+
code: "true",
|
|
48
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
49
|
+
response_type: "code",
|
|
50
|
+
redirect_uri: ANTHROPIC_REDIRECT_URI,
|
|
51
|
+
scope: SCOPES,
|
|
52
|
+
code_challenge: pkce.challenge,
|
|
53
|
+
code_challenge_method: "S256",
|
|
54
|
+
state: pkce.verifier
|
|
55
|
+
}).toString()}`,
|
|
56
|
+
callbackPort: ANTHROPIC_CALLBACK_PORT,
|
|
57
|
+
callbackPath: ANTHROPIC_CALLBACK_PATH
|
|
58
|
+
})), exchangeAnthropicCode = (code, verifier) => postToken({
|
|
59
|
+
grant_type: "authorization_code",
|
|
60
|
+
client_id: ANTHROPIC_CLIENT_ID,
|
|
61
|
+
code,
|
|
62
|
+
redirect_uri: ANTHROPIC_REDIRECT_URI,
|
|
63
|
+
code_verifier: verifier,
|
|
64
|
+
state: verifier
|
|
65
|
+
}, "code exchange");
|
|
66
|
+
const fromNullableTrimmed = (value) => Option.filter(Option.fromNullable(value), (v) => v.trim().length > 0);
|
|
67
|
+
export const parseAuthorizationInput = (input) => {
|
|
68
|
+
const value = input.trim();
|
|
69
|
+
if (value.length === 0)
|
|
70
|
+
return { code: Option.none(), state: Option.none() };
|
|
71
|
+
if (URL.canParse(value)) {
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
return {
|
|
74
|
+
code: fromNullableTrimmed(url.searchParams.get("code")),
|
|
75
|
+
state: fromNullableTrimmed(url.searchParams.get("state"))
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (value.includes("#")) {
|
|
79
|
+
const [code, state] = value.split("#", 2);
|
|
80
|
+
return { code: fromNullableTrimmed(code), state: fromNullableTrimmed(state) };
|
|
81
|
+
}
|
|
82
|
+
if (value.includes("code=")) {
|
|
83
|
+
const params = new URLSearchParams(value);
|
|
84
|
+
return {
|
|
85
|
+
code: fromNullableTrimmed(params.get("code")),
|
|
86
|
+
state: fromNullableTrimmed(params.get("state"))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { code: Option.some(value), state: Option.none() };
|
|
90
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { AuthStore } from "@xandreed/core";
|
|
3
|
+
export declare const authPaths: (cwd: string, home: string, directory?: string) => ReadonlyArray<string>;
|
|
4
|
+
/**
|
|
5
|
+
* `LocalAuthStoreLive(cwd, home)` — read-through: every call re-reads disk,
|
|
6
|
+
* so a credential added mid-session applies on the next request.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LocalAuthStoreLive: (cwd: string, home: string, directory?: string, fallbackDirectory?: string) => Layer.Layer<AuthStore, never, never>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { Effect, Layer, Option, Redacted } from "effect";
|
|
4
|
+
import { asJsonRecord, AuthError, AuthStore, parseJsonWarn } from "@xandreed/core";
|
|
5
|
+
import { refreshAnthropicToken } from "./anthropicOAuth.js";
|
|
6
|
+
import { refreshOpenAiCodexToken } from "./openAiCodexOAuth.js";
|
|
7
|
+
const decodeCredential = (value) => {
|
|
8
|
+
if (typeof value === "string")
|
|
9
|
+
return Option.some({ type: "api_key", key: value });
|
|
10
|
+
if (typeof value !== "object" || value === null)
|
|
11
|
+
return Option.none();
|
|
12
|
+
const v = value;
|
|
13
|
+
if (v.type === "api_key" && typeof v.key === "string")
|
|
14
|
+
return Option.some({ type: "api_key", key: v.key });
|
|
15
|
+
if (v.type === "oauth" && typeof v.access === "string" && typeof v.refresh === "string" && typeof v.expires === "number")
|
|
16
|
+
return Option.some({
|
|
17
|
+
type: "oauth",
|
|
18
|
+
access: v.access,
|
|
19
|
+
refresh: v.refresh,
|
|
20
|
+
expires: v.expires,
|
|
21
|
+
...typeof v.accountId === "string" ? { accountId: v.accountId } : {},
|
|
22
|
+
...typeof v.installationId === "string" ? { installationId: v.installationId } : {}
|
|
23
|
+
});
|
|
24
|
+
if (v.type === "local")
|
|
25
|
+
return Option.some({
|
|
26
|
+
type: "local",
|
|
27
|
+
...typeof v.baseUrl === "string" ? { baseUrl: v.baseUrl } : {}
|
|
28
|
+
});
|
|
29
|
+
return Option.none();
|
|
30
|
+
}, readAuthFile = (path) => Effect.tryPromise({ try: () => readFile(path, "utf-8"), catch: () => "missing" }).pipe(Effect.flatMap((text) => parseJsonWarn(text, path)), Effect.map((parsed) => {
|
|
31
|
+
const record = asJsonRecord(parsed), entries = new Map(Object.entries(record).flatMap(([provider, raw]) => Option.match(decodeCredential(raw), {
|
|
32
|
+
onNone: () => [],
|
|
33
|
+
onSome: (cred) => [[provider, cred]]
|
|
34
|
+
})));
|
|
35
|
+
return { path, entries };
|
|
36
|
+
}), Effect.orElseSucceed(() => ({ path, entries: new Map }))), writeAuthFile = (path, entries) => Effect.tryPromise({
|
|
37
|
+
try: async () => {
|
|
38
|
+
await mkdir(dirname(path), { recursive: !0 });
|
|
39
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
40
|
+
await writeFile(tmp, JSON.stringify(Object.fromEntries(entries), null, 2), "utf-8");
|
|
41
|
+
await chmod(tmp, 384);
|
|
42
|
+
await rename(tmp, path);
|
|
43
|
+
},
|
|
44
|
+
catch: (e) => new AuthError({ provider: "*", message: `auth.json write failed: ${String(e)}` })
|
|
45
|
+
}), REFRESH_SKEW_MS = 60000;
|
|
46
|
+
export const authPaths = (cwd, home, directory = ".efferent") => [
|
|
47
|
+
join(home, directory, "auth.json"),
|
|
48
|
+
join(cwd, directory, "auth.json")
|
|
49
|
+
], LocalAuthStoreLive = (cwd, home, directory = ".efferent", fallbackDirectory) => {
|
|
50
|
+
const paths = [...fallbackDirectory === void 0 ? [] : authPaths(cwd, home, fallbackDirectory), ...authPaths(cwd, home, directory)];
|
|
51
|
+
return Layer.succeed(AuthStore, {
|
|
52
|
+
all: Effect.gen(function* () {
|
|
53
|
+
const files = yield* Effect.forEach(paths, readAuthFile);
|
|
54
|
+
return new Map(files.flatMap((f) => [...f.entries]));
|
|
55
|
+
}),
|
|
56
|
+
get: (provider) => Effect.gen(function* () {
|
|
57
|
+
const files = yield* Effect.forEach(paths, readAuthFile), merged = new Map(files.flatMap((f) => [...f.entries]));
|
|
58
|
+
return Option.fromNullable(merged.get(provider));
|
|
59
|
+
}),
|
|
60
|
+
resolveKey: (provider) => Effect.gen(function* () {
|
|
61
|
+
const holder = [...yield* Effect.forEach(paths, readAuthFile)].reverse().find((f) => f.entries.has(provider)), cred = holder?.entries.get(provider);
|
|
62
|
+
if (cred === void 0)
|
|
63
|
+
return Option.none();
|
|
64
|
+
if (cred.type === "api_key")
|
|
65
|
+
return Option.some(Redacted.make(cred.key));
|
|
66
|
+
if (cred.type === "local")
|
|
67
|
+
return Option.none();
|
|
68
|
+
if (cred.expires > Date.now() + REFRESH_SKEW_MS)
|
|
69
|
+
return Option.some(Redacted.make(cred.access));
|
|
70
|
+
if (provider !== "anthropic" && provider !== "openai-codex")
|
|
71
|
+
return yield* Effect.fail(new AuthError({
|
|
72
|
+
provider,
|
|
73
|
+
message: `the ${provider} OAuth token expired and its refresh flow isn't wired on the new line \u2014 re-login or use an API key`
|
|
74
|
+
}));
|
|
75
|
+
const fresh = yield* provider === "anthropic" ? refreshAnthropicToken(cred.refresh) : refreshOpenAiCodexToken(cred.refresh), updated = { ...cred, ...fresh }, target = holder !== void 0 && authPaths(cwd, home, directory).includes(holder.path) ? holder : yield* readAuthFile(authPaths(cwd, home, directory)[0] ?? "");
|
|
76
|
+
yield* writeAuthFile(target.path, new Map([...target.entries, [provider, updated]]));
|
|
77
|
+
return Option.some(Redacted.make(fresh.access));
|
|
78
|
+
}),
|
|
79
|
+
set: (provider, credential) => Effect.gen(function* () {
|
|
80
|
+
const globalPath = authPaths(cwd, home, directory)[0] ?? "", file = yield* readAuthFile(globalPath);
|
|
81
|
+
yield* writeAuthFile(globalPath, new Map([...file.entries, [provider, credential]]));
|
|
82
|
+
}),
|
|
83
|
+
remove: (provider) => Effect.forEach(paths, (path) => Effect.gen(function* () {
|
|
84
|
+
const file = yield* readAuthFile(path);
|
|
85
|
+
if (!file.entries.has(provider))
|
|
86
|
+
return;
|
|
87
|
+
yield* writeAuthFile(path, new Map([...file.entries].filter(([p]) => p !== provider)));
|
|
88
|
+
})).pipe(Effect.asVoid)
|
|
89
|
+
});
|
|
90
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { AuthError } from "@xandreed/core";
|
|
3
|
+
/** ChatGPT Plus/Pro OAuth protocol used by the OpenAI Codex subscription. */
|
|
4
|
+
export declare const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
5
|
+
export declare const OPENAI_CODEX_CALLBACK_PORT = 1455;
|
|
6
|
+
export declare const OPENAI_CODEX_CALLBACK_PATH = "/auth/callback";
|
|
7
|
+
export declare const OPENAI_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
8
|
+
export interface OpenAiCodexTokens {
|
|
9
|
+
readonly access: string;
|
|
10
|
+
readonly refresh: string;
|
|
11
|
+
readonly expires: number;
|
|
12
|
+
readonly accountId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface OpenAiCodexOAuthBegin {
|
|
15
|
+
readonly verifier: string;
|
|
16
|
+
readonly state: string;
|
|
17
|
+
readonly authorizeUrl: string;
|
|
18
|
+
readonly callbackPort: number;
|
|
19
|
+
readonly callbackPath: string;
|
|
20
|
+
}
|
|
21
|
+
/** Extract the subscription account lane without exposing the token. */
|
|
22
|
+
export declare const openAiCodexAccountId: (accessToken: string) => Option.Option<string>;
|
|
23
|
+
export declare const beginOpenAiCodexOAuth: Effect.Effect<OpenAiCodexOAuthBegin>;
|
|
24
|
+
export declare const exchangeOpenAiCodexCode: (code: string, verifier: string) => Effect.Effect<OpenAiCodexTokens, AuthError>;
|
|
25
|
+
export declare const refreshOpenAiCodexToken: (refreshToken: string) => Effect.Effect<OpenAiCodexTokens, AuthError>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect, Either, Option } from "effect";
|
|
2
|
+
import { AuthError } from "@xandreed/core";
|
|
3
|
+
import { generatePkce } from "./anthropicOAuth.js";
|
|
4
|
+
export const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
5
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize", TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
6
|
+
export const OPENAI_CODEX_CALLBACK_PORT = 1455, OPENAI_CODEX_CALLBACK_PATH = "/auth/callback", OPENAI_CODEX_REDIRECT_URI = `http://localhost:${OPENAI_CODEX_CALLBACK_PORT}${OPENAI_CODEX_CALLBACK_PATH}`;
|
|
7
|
+
const SCOPE = "openid profile email offline_access", ACCOUNT_CLAIM = "https://api.openai.com/auth", oauthError = (stage, detail) => new AuthError({ provider: "openai-codex", message: `OAuth ${stage} failed: ${detail}` }), base64UrlJson = (segment) => {
|
|
8
|
+
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"), padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
9
|
+
return JSON.parse(atob(padded));
|
|
10
|
+
};
|
|
11
|
+
export const openAiCodexAccountId = (accessToken) => {
|
|
12
|
+
const segment = accessToken.split(".")[1];
|
|
13
|
+
return Option.flatMap(Option.fromNullable(segment), (encoded) => Option.flatMap(Either.getRight(Either.try(() => base64UrlJson(encoded))), (payload) => {
|
|
14
|
+
const claim = payload[ACCOUNT_CLAIM];
|
|
15
|
+
if (typeof claim !== "object" || claim === null)
|
|
16
|
+
return Option.none();
|
|
17
|
+
const accountId = claim.chatgpt_account_id;
|
|
18
|
+
return Option.filter(Option.fromNullable(typeof accountId === "string" ? accountId : void 0), (value) => value.length > 0);
|
|
19
|
+
}));
|
|
20
|
+
};
|
|
21
|
+
const postToken = (body, stage) => Effect.tryPromise({
|
|
22
|
+
try: async () => {
|
|
23
|
+
const response = await fetch(TOKEN_URL, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
26
|
+
body: new URLSearchParams(body),
|
|
27
|
+
signal: AbortSignal.timeout(30000)
|
|
28
|
+
});
|
|
29
|
+
return { ok: response.ok, status: response.status, text: await response.text() };
|
|
30
|
+
},
|
|
31
|
+
catch: (error) => oauthError(stage, String(error))
|
|
32
|
+
}).pipe(Effect.filterOrFail((response) => response.ok, (response) => oauthError(stage, `status ${response.status}: ${response.text.slice(0, 200)}`)), Effect.flatMap((response) => Effect.try({
|
|
33
|
+
try: () => JSON.parse(response.text),
|
|
34
|
+
catch: () => oauthError(stage, "the token response was not JSON")
|
|
35
|
+
})), Effect.filterOrFail((token) => typeof token.access_token === "string" && typeof token.refresh_token === "string" && typeof token.expires_in === "number", () => oauthError(stage, "the token response omitted required fields")), Effect.map((token) => ({
|
|
36
|
+
access: token.access_token,
|
|
37
|
+
refresh: token.refresh_token,
|
|
38
|
+
expires: Date.now() + token.expires_in * 1000,
|
|
39
|
+
...Option.match(openAiCodexAccountId(token.access_token), { onNone: () => ({}), onSome: (accountId) => ({ accountId }) })
|
|
40
|
+
})));
|
|
41
|
+
export const beginOpenAiCodexOAuth = Effect.gen(function* () {
|
|
42
|
+
const pkce = yield* generatePkce, stateBytes = new Uint8Array(16);
|
|
43
|
+
crypto.getRandomValues(stateBytes);
|
|
44
|
+
const state = Array.from(stateBytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), url = new URL(AUTHORIZE_URL);
|
|
45
|
+
Object.entries({
|
|
46
|
+
response_type: "code",
|
|
47
|
+
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
48
|
+
redirect_uri: OPENAI_CODEX_REDIRECT_URI,
|
|
49
|
+
scope: SCOPE,
|
|
50
|
+
code_challenge: pkce.challenge,
|
|
51
|
+
code_challenge_method: "S256",
|
|
52
|
+
state,
|
|
53
|
+
id_token_add_organizations: "true",
|
|
54
|
+
codex_cli_simplified_flow: "true",
|
|
55
|
+
originator: "pi"
|
|
56
|
+
}).forEach(([key, value]) => url.searchParams.set(key, value));
|
|
57
|
+
return { verifier: pkce.verifier, state, authorizeUrl: url.toString(), callbackPort: OPENAI_CODEX_CALLBACK_PORT, callbackPath: OPENAI_CODEX_CALLBACK_PATH };
|
|
58
|
+
}), exchangeOpenAiCodexCode = (code, verifier) => postToken({ grant_type: "authorization_code", client_id: OPENAI_CODEX_CLIENT_ID, code, code_verifier: verifier, redirect_uri: OPENAI_CODEX_REDIRECT_URI }, "code exchange"), refreshOpenAiCodexToken = (refreshToken) => postToken({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: OPENAI_CODEX_CLIENT_ID }, "token refresh");
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { ANTHROPIC_OAUTH_BETA, beginAnthropicOAuth, CLAUDE_CODE_SYSTEM, exchangeAnthropicCode, generatePkce, parseAuthorizationInput, refreshAnthropicToken, } from "./auth/anthropicOAuth.js";
|
|
2
|
+
export type { AuthRedirect, OAuthBegin, Pkce, RefreshedTokens } from "./auth/anthropicOAuth.js";
|
|
3
|
+
export { beginOpenAiCodexOAuth, exchangeOpenAiCodexCode, OPENAI_CODEX_CALLBACK_PATH, OPENAI_CODEX_CALLBACK_PORT, OPENAI_CODEX_CLIENT_ID, OPENAI_CODEX_REDIRECT_URI, openAiCodexAccountId, refreshOpenAiCodexToken, } from "./auth/openAiCodexOAuth.js";
|
|
4
|
+
export type { OpenAiCodexOAuthBegin, OpenAiCodexTokens } from "./auth/openAiCodexOAuth.js";
|
|
5
|
+
export { authPaths, LocalAuthStoreLive } from "./auth/localAuth.js";
|
|
6
|
+
export { LocalSettingsStoreLive } from "./settings/localSettings.js";
|
|
7
|
+
export { roleModelView } from "./settings/roleView.js";
|
|
8
|
+
export { fromChatCompletion, makeCompatLanguageModel, toChatMessages, toChatTools, } from "./llm/compat.js";
|
|
9
|
+
export type { CompatConfig } from "./llm/compat.js";
|
|
10
|
+
export { makeOpenAiCodexLanguageModel, OPENAI_CODEX_API_URL, toOpenAiCodexRequestBody } from "./llm/openAiCodex.js";
|
|
11
|
+
export { normalizeOpenAiCodexWebSocketEvent, openAiCodexUuidV7, OpenAiCodexWebSocketHttpClient } from "./llm/openAiCodexWebSocket.js";
|
|
12
|
+
export { buildProvider, OPENCODE_CHAT_URL, OPENCODE_RESPONSES_API_URL, prependClaudeCode, usesOpenCodeResponses, withAnthropicCacheBreakpoints, } from "./llm/providers.js";
|
|
13
|
+
export { classifyLlmError, LLM_REQUEST_TIMEOUT_MS, MAX_HONORED_RETRY_AFTER_MS, rejectEmptyResponse, retryableLlm, } from "./llm/retry.js";
|
|
14
|
+
export type { ErrorClass } from "./llm/retry.js";
|
|
15
|
+
export { generateWith, LanguageModelLive, LanguageModelSelectionLive } from "./llm/router.js";
|
|
16
|
+
export { configuredModelCatalog, ConfiguredModelCatalogLive, reasoningEffortsFor, } from "./llm/modelCatalog.js";
|
|
17
|
+
export type { ReasoningEffort } from "./llm/modelCatalog.js";
|
|
18
|
+
export { UtilityLlmLive } from "./llm/utilityLlm.js";
|
|
19
|
+
export { modelsPlugin, default } from "./plugin.adapter.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ANTHROPIC_OAUTH_BETA,
|
|
3
|
+
beginAnthropicOAuth,
|
|
4
|
+
CLAUDE_CODE_SYSTEM,
|
|
5
|
+
exchangeAnthropicCode,
|
|
6
|
+
generatePkce,
|
|
7
|
+
parseAuthorizationInput,
|
|
8
|
+
refreshAnthropicToken
|
|
9
|
+
} from "./auth/anthropicOAuth.js";
|
|
10
|
+
export {
|
|
11
|
+
beginOpenAiCodexOAuth,
|
|
12
|
+
exchangeOpenAiCodexCode,
|
|
13
|
+
OPENAI_CODEX_CALLBACK_PATH,
|
|
14
|
+
OPENAI_CODEX_CALLBACK_PORT,
|
|
15
|
+
OPENAI_CODEX_CLIENT_ID,
|
|
16
|
+
OPENAI_CODEX_REDIRECT_URI,
|
|
17
|
+
openAiCodexAccountId,
|
|
18
|
+
refreshOpenAiCodexToken
|
|
19
|
+
} from "./auth/openAiCodexOAuth.js";
|
|
20
|
+
export { authPaths, LocalAuthStoreLive } from "./auth/localAuth.js";
|
|
21
|
+
export { LocalSettingsStoreLive } from "./settings/localSettings.js";
|
|
22
|
+
export { roleModelView } from "./settings/roleView.js";
|
|
23
|
+
export {
|
|
24
|
+
fromChatCompletion,
|
|
25
|
+
makeCompatLanguageModel,
|
|
26
|
+
toChatMessages,
|
|
27
|
+
toChatTools
|
|
28
|
+
} from "./llm/compat.js";
|
|
29
|
+
export { makeOpenAiCodexLanguageModel, OPENAI_CODEX_API_URL, toOpenAiCodexRequestBody } from "./llm/openAiCodex.js";
|
|
30
|
+
export { normalizeOpenAiCodexWebSocketEvent, openAiCodexUuidV7, OpenAiCodexWebSocketHttpClient } from "./llm/openAiCodexWebSocket.js";
|
|
31
|
+
export {
|
|
32
|
+
buildProvider,
|
|
33
|
+
OPENCODE_CHAT_URL,
|
|
34
|
+
OPENCODE_RESPONSES_API_URL,
|
|
35
|
+
prependClaudeCode,
|
|
36
|
+
usesOpenCodeResponses,
|
|
37
|
+
withAnthropicCacheBreakpoints
|
|
38
|
+
} from "./llm/providers.js";
|
|
39
|
+
export {
|
|
40
|
+
classifyLlmError,
|
|
41
|
+
LLM_REQUEST_TIMEOUT_MS,
|
|
42
|
+
MAX_HONORED_RETRY_AFTER_MS,
|
|
43
|
+
rejectEmptyResponse,
|
|
44
|
+
retryableLlm
|
|
45
|
+
} from "./llm/retry.js";
|
|
46
|
+
export { generateWith, LanguageModelLive, LanguageModelSelectionLive } from "./llm/router.js";
|
|
47
|
+
export {
|
|
48
|
+
configuredModelCatalog,
|
|
49
|
+
ConfiguredModelCatalogLive,
|
|
50
|
+
reasoningEffortsFor
|
|
51
|
+
} from "./llm/modelCatalog.js";
|
|
52
|
+
export { UtilityLlmLive } from "./llm/utilityLlm.js";
|
|
53
|
+
export { modelsPlugin, default } from "./plugin.adapter.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { AiError, LanguageModel, Tool } from "@effect/ai";
|
|
2
|
+
import type { Prompt } from "@effect/ai";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import type { CompletionUsage } from "./sse.js";
|
|
5
|
+
/**
|
|
6
|
+
* A generic OpenAI-compatible `/chat/completions` `LanguageModel` over raw
|
|
7
|
+
* `fetch`. The official `@effect/ai-openai` client targets api.openai.com;
|
|
8
|
+
* gateways like OpenCode's speak the same protocol at a different base URL
|
|
9
|
+
* with a Bearer key, so this client is parameterized by `chatUrl` + `apiKey`.
|
|
10
|
+
*
|
|
11
|
+
* `generateText` consumes whole turns (`stream: false`); `streamText` runs
|
|
12
|
+
* the same request with `stream: true` through the pure SSE state machine in
|
|
13
|
+
* `sse.ts`. Errors BEFORE any stream part surface with the same taxonomy as
|
|
14
|
+
* `generateText` — that boundary is what the retry gate (router) keys on.
|
|
15
|
+
*/
|
|
16
|
+
type Json = Record<string, unknown>;
|
|
17
|
+
export interface CompatConfig {
|
|
18
|
+
/** Module name for `AiError` provenance (e.g. "OpenCode"). */
|
|
19
|
+
readonly moduleName: string;
|
|
20
|
+
/** Full chat-completions endpoint. */
|
|
21
|
+
readonly chatUrl: string;
|
|
22
|
+
/** Bearer key (already resolved from the AuthStore). */
|
|
23
|
+
readonly apiKey: string;
|
|
24
|
+
/** Provider-native model id. */
|
|
25
|
+
readonly model: string;
|
|
26
|
+
/** Host-selected thinking mode. Omit to retain family defaults. */
|
|
27
|
+
readonly thinking?: "enabled" | "disabled";
|
|
28
|
+
/** Gateway reasoning vocabulary, independent of provider-native thinking. */
|
|
29
|
+
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
30
|
+
readonly temperature?: number;
|
|
31
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
32
|
+
readonly fetchImpl?: typeof fetch;
|
|
33
|
+
/** Host-owned admission (e.g. reserve a worst-case cost) before every call. */
|
|
34
|
+
readonly beforeRequest?: (body: Readonly<Record<string, unknown>>) => Effect.Effect<void, AiError.AiError>;
|
|
35
|
+
/** Per-call accounting/resource boundary. Non-streaming responses are fully
|
|
36
|
+
* buffered inside request so interruption also aborts body consumption. */
|
|
37
|
+
readonly aroundRequest?: (body: Readonly<Record<string, unknown>>, request: Effect.Effect<Response, AiError.AiError>) => Effect.Effect<Response, AiError.AiError>;
|
|
38
|
+
}
|
|
39
|
+
/** Decoded `Prompt` messages → chat-completions message objects. */
|
|
40
|
+
export declare const toChatMessages: (prompt: Prompt.Prompt) => ReadonlyArray<Json>;
|
|
41
|
+
/** User-defined tools → chat-completions function declarations (empty when
|
|
42
|
+
* the request carries none — the caller omits the `tools` key entirely). */
|
|
43
|
+
export declare const toChatTools: (tools: ReadonlyArray<Tool.Any>) => ReadonlyArray<Json>;
|
|
44
|
+
/**
|
|
45
|
+
* FORCE thinking on the families that think adaptively. Live forensics
|
|
46
|
+
* (2026-07-09): kimi-k2.7-code skipped thinking on 24 turns of one forge run
|
|
47
|
+
* and emitted degenerate skeleton tool calls (write_file with an EMPTY body,
|
|
48
|
+
* 24 output tokens, finish "tool-calls") on exactly those turns — the
|
|
49
|
+
* no-think ↔ empty-call correlation was 24/24. Deepseek-style families take
|
|
50
|
+
* `thinking: {type: "enabled"}`; qwen takes `enable_thinking`. Disabling is
|
|
51
|
+
* never sent (kimi-k2.7+ rejects it outright); unknown families get nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare const thinkingParams: (model: string) => Record<string, unknown>;
|
|
54
|
+
interface ChatCompletion {
|
|
55
|
+
readonly choices?: ReadonlyArray<{
|
|
56
|
+
readonly finish_reason?: string;
|
|
57
|
+
readonly message?: {
|
|
58
|
+
readonly content?: string | null;
|
|
59
|
+
/** DeepSeek-native vocabulary (kimi-k2.7-code, deepseek serve this). */
|
|
60
|
+
readonly reasoning_content?: string | null;
|
|
61
|
+
/** OpenRouter-style vocabulary (kimi-k2.6 via Moonshot serves this). */
|
|
62
|
+
readonly reasoning?: string | null;
|
|
63
|
+
readonly tool_calls?: ReadonlyArray<{
|
|
64
|
+
readonly id?: string;
|
|
65
|
+
readonly function?: {
|
|
66
|
+
readonly name?: string;
|
|
67
|
+
readonly arguments?: string;
|
|
68
|
+
};
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
}>;
|
|
72
|
+
readonly usage?: CompletionUsage;
|
|
73
|
+
}
|
|
74
|
+
/** Parse one non-streaming completion into `@effect/ai` encoded parts. */
|
|
75
|
+
export declare const fromChatCompletion: (moduleName: string, body: ChatCompletion) => Effect.Effect<ReadonlyArray<unknown>, AiError.AiError>;
|
|
76
|
+
export declare const makeCompatLanguageModel: (config: CompatConfig) => Effect.Effect<LanguageModel.Service>;
|
|
77
|
+
export {};
|