cartethyia 2.7.1 → 2.7.2

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.
Files changed (91) hide show
  1. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-XaUmiBrC.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-CVe6RaBH.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-Dd8ZSamp.js → CliToolsPage-0XUo4UMn.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-CXGS_Ct-.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-DGnGNYnM.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-CKUFTcW4.js → CustomizationPage-DAG5J9EY.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-CzSLiDzr.js → HealthEventsModal-BKtAPYVF.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-BdmRLnq2.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-DuWzAQFL.js → OverviewPage-CqMxibJB.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-Bwotu8UJ.js → ProvidersPage-B9uWu4Nz.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js → ProxyPage-BoWOhpDO.js} +1 -1
  24. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js.gz → ProxyPage-BoWOhpDO.js.gz} +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-7P2n--Fv.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  27. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CxZ1fTkN.js} +1 -1
  28. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-BdgECjqq.js → SetupPage-Co12Zzaw.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-Dqf4zpS-.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-Byj2lAEm.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-CTkLAuRc.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-ClCMCWWQ.js} +3 -3
  38. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  39. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-CWWZ-uCZ.js} +1 -1
  40. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  41. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-DFVFrnWW.js} +2 -2
  42. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  43. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-DBNVLdUu.js} +1 -1
  44. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  45. package/dist/dashboard/index.html +1 -1
  46. package/dist/dashboard/index.html.gz +0 -0
  47. package/dist/dashboard/precompressed-assets.json +22 -22
  48. package/package.json +1 -1
  49. package/src/console/cli-tools/contracts.ts +10 -4
  50. package/src/network/outbound-fetch.ts +281 -226
  51. package/src/network/pool/agent.ts +7 -0
  52. package/src/providers/default-registry.ts +1 -0
  53. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  54. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  55. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  56. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  57. package/src/providers/integrations/qoder/qoder.ts +1373 -1641
  58. package/src/providers/operations/account-error-classification.ts +1684 -1660
  59. package/src/providers/operations/account-health-service.ts +621 -621
  60. package/src/providers/operations/provider-catalog-service.ts +11 -1
  61. package/src/providers/provider-contracts.ts +7 -2
  62. package/src/security/cli-client-fingerprint.ts +9 -2
  63. package/src/transport/dispatch/empty-stream.ts +224 -224
  64. package/src/transport/failure-policy.ts +33 -0
  65. package/src/transport/routing/route-catalog.ts +34 -12
  66. package/src/transport/routing/router.ts +28 -0
  67. package/src/transport/surface/responses/encode.ts +2 -3
  68. package/src/transport/surface/responses/parse.ts +49 -7
  69. package/src/types/undici.d.ts +33 -0
  70. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  71. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  72. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  73. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  74. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  75. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  76. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  77. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  78. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  79. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  80. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  81. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  82. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  83. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  84. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  85. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  86. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  87. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  88. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  89. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  90. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  91. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
@@ -1,226 +1,281 @@
1
- // Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
2
- import { GatewayError } from "../protocol/gateway-error";
3
- import { Agent as HttpAgent } from "node:http";
4
- import { Agent as HttpsAgent } from "node:https";
5
- import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
6
- import type { SsrfPolicy } from "../config/network";
7
- import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
8
- import { metrics } from "../observability/metrics";
9
- import { trackNetworkCall } from "../observability/performance-metrics";
10
- // Validated fetch — single egress with ProxyAgent support
11
- export interface ValidatedFetchOptions {
12
- readonly fetchFn?: typeof fetch;
13
- readonly policy?: SsrfPolicy;
14
- readonly maxRedirects?: number;
15
- readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
16
- readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
17
- /**
18
- * Proxy URL to route the dial through, forwarded to the runtime-native fetch
19
- * (`proxy` option). Pool egress passes the pool's endpoint here; without it
20
- * a request that should leave through a proxy leaves directly.
21
- */
22
- readonly proxyUrl?: string;
23
- }
24
- export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
25
-
26
- /**
27
- * Whether a configured pool/relay agent owns this dial. When it does, the
28
- * proxy resolves the upstream target itself, so the local resolve below is an
29
- * advisory SSRF check rather than a prerequisite for egress.
30
- */
31
-
32
- /**
33
- * Builds the application-relay request used by hosted relay front doors.
34
- * These endpoints intentionally do not receive CONNECT: they accept the
35
- * target origin/path in headers and perform the upstream fetch themselves,
36
- * matching the 21-beta relay contract.
37
- */
38
- function relayRequest(
39
- relayEndpoint: URL,
40
- target: URL,
41
- init: RequestInit,
42
- ): { url: URL; init: RequestInit } {
43
- const relayUrl = new URL(relayEndpoint.toString());
44
- const headers = new Headers(init.headers);
45
- headers.delete("host");
46
- headers.set("x-relay-target", target.origin);
47
- headers.set("x-relay-path", `${target.pathname}${target.search}`);
48
- if (relayUrl.username || relayUrl.password) {
49
- const credentials = `${decodeURIComponent(relayUrl.username)}:${decodeURIComponent(relayUrl.password)}`;
50
- headers.set("x-relay-auth", `Basic ${Buffer.from(credentials).toString("base64")}`);
51
- relayUrl.username = "";
52
- relayUrl.password = "";
53
- }
54
- return { url: relayUrl, init: { ...init, headers } };
55
- }
56
-
57
- const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
58
-
59
- function stripCredentialsOnCrossOrigin(
60
- headersInit: HeadersInit | undefined,
61
- ): Headers {
62
- const headers = new Headers(headersInit);
63
- for (const name of CREDENTIAL_HEADERS) headers.delete(name);
64
- return headers;
65
- }
66
-
67
- /**
68
- * The injected dialer. `fetchFn` is the seam offline suites use to record
69
- * requests without a socket; the runtime fetch is the only other dialer.
70
- */
71
- function dialer(options: ValidatedFetchOptions): typeof fetch {
72
- return options.fetchFn ?? fetch;
73
- }
74
-
75
- /**
76
- * One dial, always through `fetch`.
77
- *
78
- * The runtime's own fetch (Bun/Undici) is the transport the clients this
79
- * gateway impersonates actually use, and several upstreams meter per client
80
- * transport fingerprint — OpenCode's anonymous free tier refuses a
81
- * Node-stack dial with 429 FreeUsageLimitError while accepting the identical
82
- * bytes over this one. There is no Node `http`/`https` path to choose
83
- * between: the SSRF resolve/validate pass that runs before this dial is the
84
- * security boundary, and it is identical for every caller.
85
- *
86
- * A configured pool/relay agent is still honored through `dispatcher` when the
87
- * runtime supports it (Bun and Undici both do); when it is not supported the
88
- * agent is ignored rather than silently replaced by an unvalidated dial.
89
- *
90
- * `content-length` is set for a known-length body only when the caller did not
91
- * set it: without the header the request falls back to chunked encoding, and
92
- * some upstream gateways wait for the complete chunked stream before routing
93
- * the request — an avoidable round trip per dial.
94
- */
95
- async function dial(
96
- url: URL,
97
- init: RequestInit,
98
- fetchFn: typeof fetch,
99
- proxyUrl: string | undefined,
100
- ): Promise<Response> {
101
- const headers = new Headers(init.headers);
102
- const rawBody = init.body;
103
- if (rawBody !== undefined && rawBody !== null && !headers.has("content-length")) {
104
- const bytes =
105
- typeof rawBody === "string"
106
- ? Buffer.byteLength(rawBody, "utf8")
107
- : Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
108
- ? rawBody.byteLength
109
- : (await new Response(rawBody).arrayBuffer()).byteLength;
110
- headers.set("content-length", String(bytes));
111
- }
112
- return await fetchFn(url, {
113
- ...init,
114
- headers,
115
- ...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
116
- });
117
- }
118
-
119
- export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
120
- const policy = options.policy ?? {};
121
- const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
122
- const resolveFn = options.resolveFn ?? resolveAllAddresses;
123
- const fetchFn = dialer(options);
124
- const configuredAgent = options.agent;
125
- const relayEndpoint = isProxyAgentPair(configuredAgent)
126
- ? configuredAgent.relayEndpoint
127
- : undefined;
128
- const proxyUrl =
129
- options.proxyUrl ?? (isProxyAgentPair(configuredAgent) ? configuredAgent.proxyUrl : undefined);
130
- return async (input, init = {}) => {
131
- const startedAt = performance.now();
132
- let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
133
- let requestInit: RequestInit = { ...init, redirect: "manual" };
134
- if (input instanceof Request && init.headers === undefined) {
135
- requestInit.headers = new Headers(input.headers);
136
- }
137
- for (let hop = 0; hop <= maxRedirects; hop += 1) {
138
- if (url.protocol !== "http:" && url.protocol !== "https:")
139
- throw new TypeError("unsupported outbound URL protocol");
140
- const signal = requestInit.signal ?? new AbortController().signal;
141
- // Every hop — including proxied dials and redirect targets — is resolved
142
- // and checked against the SSRF policy. When a proxy agent owns the dial,
143
- // the proxy still resolves its own answer, so the check is advisory there;
144
- // it deterministically rejects targets whose *advertised* addresses fall
145
- // in blocked ranges (literal-IP redirects, private-space DNS, relay abuse).
146
- // For a pool/relay-owned dial, the upstream *target* is resolved by the
147
- // proxy/relay itself, so local DNS for it is an advisory SSRF check,
148
- // not a prerequisite for egress. Operator VPNs routinely break the
149
- // system resolver (tunnel DNS that is unreachable or drops queries);
150
- const poolDialsTarget = Boolean(configuredAgent || relayEndpoint);
151
- let resolvedAddress: string | undefined;
152
- let relayResolvedAddress: string | undefined;
153
- if (poolDialsTarget) {
154
- // The egress dials the pool/relay, which resolves the target itself.
155
- // Counting these lets ops confirm how much traffic depends on
156
- // proxy-resolved DNS rather than the local system resolver.
157
- metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
158
- }
159
- try {
160
- if (!poolDialsTarget) {
161
- const addresses = await resolveFn(url.hostname, signal);
162
- validateResolvedAddresses(addresses, policy);
163
- resolvedAddress = addresses[0];
164
- }
165
- if (relayEndpoint) {
166
- const relayAddresses = await resolveFn(relayEndpoint.hostname, signal);
167
- validateResolvedAddresses(relayAddresses, policy);
168
- relayResolvedAddress = relayAddresses[0];
169
- if (!relayResolvedAddress)
170
- throw new TypeError(`no validated address for ${relayEndpoint.hostname}`);
171
- }
172
- } catch (error) {
173
- // A pool/relay-owned dial still needs its egress host locally; only
174
- // the advisory target lookup may degrade.
175
- if (relayEndpoint && !relayResolvedAddress) throw error;
176
- if (!poolDialsTarget) throw new TypeError(`no validated address for ${url.hostname}`);
177
- }
178
- if (!poolDialsTarget && !resolvedAddress)
179
- throw new TypeError(`no validated address for ${url.hostname}`);
180
- let response: Response;
181
- if (relayEndpoint) {
182
- const relay = relayRequest(relayEndpoint, url, requestInit);
183
- // The relay front door is reached directly: it reads the target from
184
- // `x-relay-target`/`x-relay-path` and performs the upstream fetch
185
- // itself, so the runtime fetch must NOT be given `proxy` here — that
186
- // would make it attempt a CONNECT tunnel against the relay, which a
187
- // serverless front door refuses (400).
188
- response = await dial(relay.url, relay.init, fetchFn, undefined);
189
- } else {
190
- response = await dial(url, requestInit, fetchFn, proxyUrl);
191
- }
192
- if (response.status < 300 || response.status >= 400) {
193
- trackNetworkCall(url.hostname, performance.now() - startedAt);
194
- return response;
195
- }
196
- const location = response.headers.get("location");
197
- if (!location) {
198
- trackNetworkCall(url.hostname, performance.now() - startedAt);
199
- return response;
200
- }
201
- // The redirect response body is never consumed: release its socket
202
- // before following the next hop instead of leaving it open.
203
- await response.body?.cancel().catch(() => undefined);
204
- const nextUrl = new URL(location, url);
205
- const isCrossOriginRedirect = nextUrl.origin !== url.origin;
206
- if (isCrossOriginRedirect) {
207
- requestInit = { ...requestInit, headers: stripCredentialsOnCrossOrigin(requestInit.headers) };
208
- }
209
- if (isCrossOriginRedirect && (response.status === 307 || response.status === 308)) {
210
- const method = (requestInit.method ?? "GET").toUpperCase();
211
- if (method !== "GET" && method !== "HEAD" && requestInit.body !== undefined) {
212
- throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked");
213
- }
214
- }
215
- url = nextUrl;
216
- if (response.status === 301 || response.status === 302 || response.status === 303) {
217
- const method = (requestInit.method ?? "GET").toUpperCase();
218
- if (method !== "GET" && method !== "HEAD") {
219
- const { body: _body, ...withoutBody } = requestInit;
220
- requestInit = { ...withoutBody, method: "GET" };
221
- }
222
- }
223
- }
224
- throw new Error("outbound redirect limit exceeded");
225
- };
226
- }
1
+ // Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
2
+ import { GatewayError } from "../protocol/gateway-error";
3
+ import { Agent as HttpAgent } from "node:http";
4
+ import { Agent as HttpsAgent } from "node:https";
5
+ import { Agent as UndiciAgent, fetch as undiciFetch, type UndiciRequestInit } from "undici";
6
+ import { SocksProxyAgent } from "socks-proxy-agent";
7
+ import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
8
+ import type { SsrfPolicy } from "../config/network";
9
+ import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
10
+ import { metrics } from "../observability/metrics";
11
+ import { trackNetworkCall } from "../observability/performance-metrics";
12
+ // Validated fetch — single egress with ProxyAgent support
13
+ export interface ValidatedFetchOptions {
14
+ readonly fetchFn?: typeof fetch;
15
+ readonly policy?: SsrfPolicy;
16
+ readonly maxRedirects?: number;
17
+ readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
18
+ readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
19
+ /**
20
+ * Proxy URL to route the dial through, forwarded to the runtime-native fetch
21
+ * (`proxy` option). Pool egress passes the pool's endpoint here; without it
22
+ * a request that should leave through a proxy leaves directly.
23
+ */
24
+ readonly proxyUrl?: string;
25
+ /**
26
+ * Fetch implementation used for SOCKS5 dials (via the undici dispatcher).
27
+ * Defaults to undici's fetch; injectable for offline tests.
28
+ */
29
+ readonly socksFetch?: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response>;
30
+ }
31
+ export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
32
+
33
+ /**
34
+ * Whether a configured pool/relay agent owns this dial. When it does, the
35
+ * proxy resolves the upstream target itself, so the local resolve below is an
36
+ * advisory SSRF check rather than a prerequisite for egress.
37
+ */
38
+
39
+ /**
40
+ * Builds the application-relay request used by hosted relay front doors.
41
+ * These endpoints intentionally do not receive CONNECT: they accept the
42
+ * target origin/path in headers and perform the upstream fetch themselves,
43
+ * matching the 21-beta relay contract.
44
+ */
45
+ function relayRequest(
46
+ relayEndpoint: URL,
47
+ target: URL,
48
+ init: RequestInit,
49
+ ): { url: URL; init: RequestInit } {
50
+ const relayUrl = new URL(relayEndpoint.toString());
51
+ const headers = new Headers(init.headers);
52
+ headers.delete("host");
53
+ headers.set("x-relay-target", target.origin);
54
+ headers.set("x-relay-path", `${target.pathname}${target.search}`);
55
+ if (relayUrl.username || relayUrl.password) {
56
+ const credentials = `${decodeURIComponent(relayUrl.username)}:${decodeURIComponent(relayUrl.password)}`;
57
+ headers.set("x-relay-auth", `Basic ${Buffer.from(credentials).toString("base64")}`);
58
+ relayUrl.username = "";
59
+ relayUrl.password = "";
60
+ }
61
+ return { url: relayUrl, init: { ...init, headers } };
62
+ }
63
+
64
+ const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
65
+
66
+ function stripCredentialsOnCrossOrigin(
67
+ headersInit: HeadersInit | undefined,
68
+ ): Headers {
69
+ const headers = new Headers(headersInit);
70
+ for (const name of CREDENTIAL_HEADERS) headers.delete(name);
71
+ return headers;
72
+ }
73
+
74
+ /**
75
+ * The injected dialer. `fetchFn` is the seam offline suites use to record
76
+ * requests without a socket; the runtime fetch is the only other dialer.
77
+ */
78
+ function dialer(options: ValidatedFetchOptions): typeof fetch {
79
+ return options.fetchFn ?? fetch;
80
+ }
81
+
82
+ /**
83
+ * Builds the `undici` dispatcher that routes a dial through a SOCKS5 pool
84
+ * agent. The runtime-native fetch does not support `socks5://` proxies
85
+ * (`UnsupportedProxyProtocol`), so the socks pair drives the transport via
86
+ * undici's dispatcher instead — no node `http`/`https` adapter is involved.
87
+ */
88
+ let socksDispatcherCache: WeakMap<SocksProxyAgent, UndiciAgent> | undefined;
89
+ function socksDispatcherFor(agent: SocksProxyAgent): UndiciAgent {
90
+ socksDispatcherCache ??= new WeakMap();
91
+ let dispatcher = socksDispatcherCache.get(agent);
92
+ if (!dispatcher) {
93
+ dispatcher = new UndiciAgent({
94
+ connect: async (opts) => {
95
+ const o = opts as {
96
+ hostname?: string;
97
+ port?: number | string;
98
+ family?: number;
99
+ lookup?: (hostname: string, options: unknown, cb: (err: Error | null, addresses: unknown) => void) => void;
100
+ };
101
+ return await agent.connect(
102
+ {} as never,
103
+ {
104
+ host: o.hostname ?? "localhost",
105
+ port: Number(o.port ?? 80),
106
+ ...(o.family === undefined ? {} : { family: o.family }),
107
+ ...(o.lookup === undefined ? {} : { lookup: o.lookup }),
108
+ } as never,
109
+ );
110
+ },
111
+ });
112
+ socksDispatcherCache.set(agent, dispatcher);
113
+ }
114
+ return dispatcher;
115
+ }
116
+
117
+ /**
118
+ * One dial, always through `fetch`.
119
+ *
120
+ * The runtime's own fetch (Bun/Undici) is the transport the clients this
121
+ * gateway impersonates actually use, and several upstreams meter per client
122
+ * transport fingerprint — OpenCode's anonymous free tier refuses a
123
+ * Node-stack dial with 429 FreeUsageLimitError while accepting the identical
124
+ * bytes over this one. There is no Node `http`/`https` path to choose
125
+ * between: the SSRF resolve/validate pass that runs before this dial is the
126
+ * security boundary, and it is identical for every caller.
127
+ *
128
+ * A configured pool/relay agent is still honored through `dispatcher` when the
129
+ * runtime supports it (Bun and Undici both do); when it is not supported the
130
+ * agent is ignored rather than silently replaced by an unvalidated dial.
131
+ *
132
+ * `content-length` is set for a known-length body only when the caller did not
133
+ * set it: without the header the request falls back to chunked encoding, and
134
+ * some upstream gateways wait for the complete chunked stream before routing
135
+ * the request — an avoidable round trip per dial.
136
+ */
137
+ async function dial(
138
+ url: URL,
139
+ init: RequestInit,
140
+ fetchFn: typeof fetch,
141
+ proxyUrl: string | undefined,
142
+ dispatcher?: UndiciAgent,
143
+ socksFetch: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response> = undiciFetch,
144
+ ): Promise<Response> {
145
+ const headers = new Headers(init.headers);
146
+ const rawBody = init.body;
147
+ if (rawBody !== undefined && rawBody !== null && !headers.has("content-length")) {
148
+ const bytes =
149
+ typeof rawBody === "string"
150
+ ? Buffer.byteLength(rawBody, "utf8")
151
+ : Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
152
+ ? rawBody.byteLength
153
+ : (await new Response(rawBody).arrayBuffer()).byteLength;
154
+ headers.set("content-length", String(bytes));
155
+ }
156
+ if (dispatcher !== undefined) {
157
+ return await socksFetch(url, {
158
+ ...init,
159
+ headers,
160
+ dispatcher,
161
+ });
162
+ }
163
+ return await fetchFn(url, {
164
+ ...init,
165
+ headers,
166
+ ...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
167
+ });
168
+ }
169
+
170
+ export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
171
+ const policy = options.policy ?? {};
172
+ const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
173
+ const resolveFn = options.resolveFn ?? resolveAllAddresses;
174
+ const fetchFn = dialer(options);
175
+ const configuredAgent = options.agent;
176
+ const relayEndpoint = isProxyAgentPair(configuredAgent)
177
+ ? configuredAgent.relayEndpoint
178
+ : undefined;
179
+ const socksAgent = isProxyAgentPair(configuredAgent)
180
+ ? configuredAgent.socks
181
+ : undefined;
182
+ const socksFetch = options.socksFetch ?? undiciFetch;
183
+ const proxyUrl =
184
+ options.proxyUrl ?? (isProxyAgentPair(configuredAgent) ? configuredAgent.proxyUrl : undefined);
185
+ return async (input, init = {}) => {
186
+ const startedAt = performance.now();
187
+ let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
188
+ let requestInit: RequestInit = { ...init, redirect: "manual" };
189
+ if (input instanceof Request && init.headers === undefined) {
190
+ requestInit.headers = new Headers(input.headers);
191
+ }
192
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
193
+ if (url.protocol !== "http:" && url.protocol !== "https:")
194
+ throw new TypeError("unsupported outbound URL protocol");
195
+ const signal = requestInit.signal ?? new AbortController().signal;
196
+ // Every hop — including proxied dials and redirect targets — is resolved
197
+ // and checked against the SSRF policy. When a proxy agent owns the dial,
198
+ // the proxy still resolves its own answer, so the check is advisory there;
199
+ // it deterministically rejects targets whose *advertised* addresses fall
200
+ // in blocked ranges (literal-IP redirects, private-space DNS, relay abuse).
201
+ // For a pool/relay-owned dial, the upstream *target* is resolved by the
202
+ // proxy/relay itself, so local DNS for it is an advisory SSRF check,
203
+ // not a prerequisite for egress. Operator VPNs routinely break the
204
+ // system resolver (tunnel DNS that is unreachable or drops queries);
205
+ const poolDialsTarget = Boolean(configuredAgent || relayEndpoint);
206
+ let resolvedAddress: string | undefined;
207
+ let relayResolvedAddress: string | undefined;
208
+ if (poolDialsTarget) {
209
+ // The egress dials the pool/relay, which resolves the target itself.
210
+ // Counting these lets ops confirm how much traffic depends on
211
+ // proxy-resolved DNS rather than the local system resolver.
212
+ metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
213
+ }
214
+ try {
215
+ if (!poolDialsTarget) {
216
+ const addresses = await resolveFn(url.hostname, signal);
217
+ validateResolvedAddresses(addresses, policy);
218
+ resolvedAddress = addresses[0];
219
+ }
220
+ if (relayEndpoint) {
221
+ const relayAddresses = await resolveFn(relayEndpoint.hostname, signal);
222
+ validateResolvedAddresses(relayAddresses, policy);
223
+ relayResolvedAddress = relayAddresses[0];
224
+ if (!relayResolvedAddress)
225
+ throw new TypeError(`no validated address for ${relayEndpoint.hostname}`);
226
+ }
227
+ } catch (error) {
228
+ // A pool/relay-owned dial still needs its egress host locally; only
229
+ // the advisory target lookup may degrade.
230
+ if (relayEndpoint && !relayResolvedAddress) throw error;
231
+ if (!poolDialsTarget) throw new TypeError(`no validated address for ${url.hostname}`);
232
+ }
233
+ if (!poolDialsTarget && !resolvedAddress)
234
+ throw new TypeError(`no validated address for ${url.hostname}`);
235
+ let response: Response;
236
+ if (relayEndpoint) {
237
+ const relay = relayRequest(relayEndpoint, url, requestInit);
238
+ // The relay front door is reached directly: it reads the target from
239
+ // `x-relay-target`/`x-relay-path` and performs the upstream fetch
240
+ // itself, so the runtime fetch must NOT be given `proxy` here — that
241
+ // would make it attempt a CONNECT tunnel against the relay, which a
242
+ // serverless front door refuses (400).
243
+ response = await dial(relay.url, relay.init, fetchFn, undefined);
244
+ } else {
245
+ response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
246
+ }
247
+ if (response.status < 300 || response.status >= 400) {
248
+ trackNetworkCall(url.hostname, performance.now() - startedAt);
249
+ return response;
250
+ }
251
+ const location = response.headers.get("location");
252
+ if (!location) {
253
+ trackNetworkCall(url.hostname, performance.now() - startedAt);
254
+ return response;
255
+ }
256
+ // The redirect response body is never consumed: release its socket
257
+ // before following the next hop instead of leaving it open.
258
+ await response.body?.cancel().catch(() => undefined);
259
+ const nextUrl = new URL(location, url);
260
+ const isCrossOriginRedirect = nextUrl.origin !== url.origin;
261
+ if (isCrossOriginRedirect) {
262
+ requestInit = { ...requestInit, headers: stripCredentialsOnCrossOrigin(requestInit.headers) };
263
+ }
264
+ if (isCrossOriginRedirect && (response.status === 307 || response.status === 308)) {
265
+ const method = (requestInit.method ?? "GET").toUpperCase();
266
+ if (method !== "GET" && method !== "HEAD" && requestInit.body !== undefined) {
267
+ throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked");
268
+ }
269
+ }
270
+ url = nextUrl;
271
+ if (response.status === 301 || response.status === 302 || response.status === 303) {
272
+ const method = (requestInit.method ?? "GET").toUpperCase();
273
+ if (method !== "GET" && method !== "HEAD") {
274
+ const { body: _body, ...withoutBody } = requestInit;
275
+ requestInit = { ...withoutBody, method: "GET" };
276
+ }
277
+ }
278
+ }
279
+ throw new Error("outbound redirect limit exceeded");
280
+ };
281
+ }
@@ -315,6 +315,12 @@ export interface ProxyAgentPair {
315
315
  * the URL is what carries the pool egress.
316
316
  */
317
317
  readonly proxyUrl?: string;
318
+ /**
319
+ * The SOCKS5 agent instance for socks pools. The runtime-native fetch does
320
+ * not support `socks5://` proxies, so the validated fetcher drives this via
321
+ * the `undici` dispatcher when the pool is a socks pair.
322
+ */
323
+ readonly socks?: SocksProxyAgent;
318
324
  }
319
325
 
320
326
  export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
@@ -479,6 +485,7 @@ export function createSocks5Agent(
479
485
  return {
480
486
  http: http as unknown as HttpAgent,
481
487
  https: https as unknown as HttpsAgent,
488
+ socks: http,
482
489
  proxyUrl: withCredential(url, credential).toString(),
483
490
  };
484
491
  }
@@ -714,6 +714,7 @@ export const PROVIDER_CAPABILITIES = {
714
714
  responses: "/responses",
715
715
  },
716
716
  loadAdapter: async () => createApiKeyAdapter(GENERIC_API_KEY_SPECS.ollamacloud),
717
+ loadModels: async () => (await import("./integrations/ollamacloud-models")).OLLAMACLOUD_MODELS,
717
718
  loadQuotaCollector: quotaCapability(() => import("./integrations/ollamacloud-quota"), "fetchOllamaQuota"),
718
719
  loadModelDiscovery: openAIModelDiscovery("ollamacloud", { headers: (credential) => ({ authorization: `Bearer ${credential}` }) }),
719
720
  },
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Ollama Cloud (`ollamacloud`) model catalog.
3
+ *
4
+ * The provider is discovery-backed: live `/v1/models` on `https://ollama.com/v1`
5
+ * is the primary source and publishes every model's real context window. This
6
+ * static floor keeps the models an operator most likely wants routable before
7
+ * any account sync — and pins the verified live limits so a cold process never
8
+ * falls back to the invented 200k/64k defaults.
9
+ *
10
+ * Limits below are taken from the live `/v1/models` roster (verified 2026-10):
11
+ * - `gemma4:31b` -> 262144 ctx, text+image, thinking none/medium
12
+ * - `gpt-oss:20b` / `gpt-oss:120b` -> 131072 ctx, text, reasoning low/med/high
13
+ * - `nemotron-3-nano:30b` / `nemotron-3-super` / `nemotron-3-ultra`
14
+ * -> 262144 ctx, text, thinking none/medium
15
+ *
16
+ * All six serve the chat wire (none is a Responses-native id), and all report
17
+ * `supported_in_api: true` on the live roster.
18
+ */
19
+ import { defineModel } from "../provider-model-definition";
20
+ import type { ModelDefinition } from "../provider-model-definition";
21
+
22
+ export const OLLAMACLOUD_PROVIDER_ID = "ollamacloud" as const;
23
+
24
+ /** Chat path below the `/v1`-rooted base. */
25
+ const CHAT_ENDPOINT = "/chat/completions";
26
+
27
+ type CatalogRow = readonly [id: string, ctx: number, vision: boolean, reasoning: boolean];
28
+
29
+ function ollamaCloudModel([id, ctx, vision, reasoning]: CatalogRow): ModelDefinition {
30
+ return defineModel({
31
+ id,
32
+ providerId: OLLAMACLOUD_PROVIDER_ID,
33
+ wireFamily: "chat",
34
+ endpoint: CHAT_ENDPOINT,
35
+ ctx,
36
+ vision,
37
+ reasoning,
38
+ toolCall: true,
39
+ });
40
+ }
41
+
42
+ /** Static Ollama Cloud floor; live discovery adds to it per account. */
43
+ export const OLLAMACLOUD_MODELS: readonly ModelDefinition[] = [
44
+ ["gemma4:31b", 262_144, true, true] as const,
45
+ ["gpt-oss:20b", 131_072, false, true] as const,
46
+ ["gpt-oss:120b", 131_072, false, true] as const,
47
+ ["nemotron-3-nano:30b", 262_144, false, true] as const,
48
+ ["nemotron-3-super", 262_144, false, true] as const,
49
+ ["nemotron-3-ultra", 262_144, false, true] as const,
50
+ ].map(ollamaCloudModel);
@@ -150,7 +150,18 @@ export interface QoderOAuthState {
150
150
 
151
151
  /** Reads the persisted Qoder identity; `undefined` when the account predates it. */
152
152
  export function parseQoderOAuthState(value: unknown): QoderOAuthState | undefined {
153
- const root = record(value);
153
+ let root = record(value);
154
+ if (root === undefined && typeof value === "string") {
155
+ // The credential loader stores `auth_state` as JSON text in SQLite, so
156
+ // the resolver hands the adapter a string when the cache was cold. Parse
157
+ // it before giving up — a login that works once must work again after the
158
+ // cache is evicted.
159
+ try {
160
+ root = record(JSON.parse(value));
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
154
165
  if (root === undefined) return undefined;
155
166
  const userId = nonEmptyTrimmedString(root.userId);
156
167
  const machineId = nonEmptyTrimmedString(root.machineId);