@plurnk/plurnk-providers 1.3.5 → 1.3.6

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 (84) hide show
  1. package/.env.defaults +35 -45
  2. package/README.md +44 -53
  3. package/SPEC.md +215 -371
  4. package/dist/AiSdkProvider.d.ts +78 -0
  5. package/dist/AiSdkProvider.d.ts.map +1 -0
  6. package/dist/AiSdkProvider.js +591 -0
  7. package/dist/AiSdkProvider.js.map +1 -0
  8. package/dist/OpenAICompat.d.ts +1 -2
  9. package/dist/OpenAICompat.d.ts.map +1 -1
  10. package/dist/OpenAICompat.js +39 -117
  11. package/dist/OpenAICompat.js.map +1 -1
  12. package/dist/ProviderRegistry.d.ts.map +1 -1
  13. package/dist/ProviderRegistry.js +37 -24
  14. package/dist/ProviderRegistry.js.map +1 -1
  15. package/dist/aiSdkTransport.d.ts +52 -0
  16. package/dist/aiSdkTransport.d.ts.map +1 -0
  17. package/dist/aiSdkTransport.js +294 -0
  18. package/dist/aiSdkTransport.js.map +1 -0
  19. package/dist/catalogProvider.d.ts +15 -0
  20. package/dist/catalogProvider.d.ts.map +1 -0
  21. package/dist/catalogProvider.js +103 -0
  22. package/dist/catalogProvider.js.map +1 -0
  23. package/dist/compatibleProvider.d.ts +3 -0
  24. package/dist/compatibleProvider.d.ts.map +1 -0
  25. package/dist/compatibleProvider.js +146 -0
  26. package/dist/compatibleProvider.js.map +1 -0
  27. package/dist/discover.d.ts.map +1 -1
  28. package/dist/discover.js.map +1 -1
  29. package/dist/env.d.ts +1 -0
  30. package/dist/env.d.ts.map +1 -1
  31. package/dist/env.js +13 -6
  32. package/dist/env.js.map +1 -1
  33. package/dist/index.d.ts +4 -6
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -7
  36. package/dist/index.js.map +1 -1
  37. package/dist/ollama.d.ts +3 -0
  38. package/dist/ollama.d.ts.map +1 -0
  39. package/dist/ollama.js +39 -0
  40. package/dist/ollama.js.map +1 -0
  41. package/dist/openai.d.ts +2 -4
  42. package/dist/openai.d.ts.map +1 -1
  43. package/dist/openai.js +1 -2
  44. package/dist/openai.js.map +1 -1
  45. package/dist/sdkModels.d.ts +13 -0
  46. package/dist/sdkModels.d.ts.map +1 -0
  47. package/dist/sdkModels.js +153 -0
  48. package/dist/sdkModels.js.map +1 -0
  49. package/dist/standardProviders.d.ts.map +1 -1
  50. package/dist/standardProviders.js +0 -1
  51. package/dist/standardProviders.js.map +1 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/telemetry.js +20 -9
  54. package/dist/telemetry.js.map +1 -1
  55. package/dist/types.d.ts +3 -2
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +18 -10
  58. package/src/{OpenAICompat.test.ts → AiSdkProvider.test.ts} +193 -152
  59. package/src/{OpenAICompat.ts → AiSdkProvider.ts} +83 -127
  60. package/src/Mock.test.ts +1 -1
  61. package/src/ProviderRegistry.test.ts +40 -27
  62. package/src/ProviderRegistry.ts +35 -24
  63. package/src/aiSdkTransport.test.ts +253 -0
  64. package/src/aiSdkTransport.ts +369 -0
  65. package/src/boundaries.test.ts +2 -2
  66. package/src/catalogProvider.test.ts +100 -0
  67. package/src/catalogProvider.ts +151 -0
  68. package/src/compatibleProvider.test.ts +44 -0
  69. package/src/compatibleProvider.ts +205 -0
  70. package/src/discover.test.ts +12 -12
  71. package/src/discover.ts +3 -6
  72. package/src/env.ts +14 -6
  73. package/src/index.ts +6 -10
  74. package/src/ollama.ts +63 -0
  75. package/src/openai.ts +2 -8
  76. package/src/sdkModels.test.ts +47 -0
  77. package/src/sdkModels.ts +194 -0
  78. package/src/telemetry.test.ts +17 -10
  79. package/src/telemetry.ts +22 -14
  80. package/src/types.ts +5 -8
  81. package/src/aiSdkAdapter.spike.test.ts +0 -242
  82. package/src/openaiStream.ts +0 -310
  83. package/src/standardProviders.test.ts +0 -939
  84. package/src/standardProviders.ts +0 -631
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export type {
3
3
  FinishReason,
4
4
  Provider,
5
5
  ProviderAssistant,
6
- ProviderFactory,
6
+ AiSdkProviderPlugin,
7
7
  ProviderOptions,
8
8
  ProviderResponse,
9
9
  ProviderUsage,
@@ -23,28 +23,24 @@ export {
23
23
  resetDiscoveryCache,
24
24
  } from "./ProviderRegistry.ts";
25
25
 
26
- // Scope-agnostic tier-2 discovery (SPEC §5) — exported so a consumer can list
27
- // installed providers (first-party + third-party) without instantiating them.
26
+ // Scope-agnostic plugin discovery (SPEC §5).
28
27
  export { discover } from "./discover.ts";
29
28
  export type { DiscoverOptions, Discovery } from "./discover.ts";
30
29
 
31
- // Shared OpenAI-compatible transport machinery — the spine every sibling
32
- // extends and the basis for ./standardProviders.ts.
33
- export { default as OpenAICompatProvider, effortFromBudget } from "./OpenAICompat.ts";
34
- export type { OpenAICompatConfig, ReasoningStyle, GrammarStyle } from "./OpenAICompat.ts";
30
+ // Stable PLURNK adapter over AI SDK language models and compatible local URLs.
31
+ export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
32
+ export type { AiSdkProviderConfig, ReasoningStyle, GrammarStyle } from "./AiSdkProvider.ts";
35
33
  // Capacity pool (SPEC §15): front N interchangeable backends as one Provider -
36
34
  // worker-sticky for KV-cache reuse, overflow to a healthy sibling; the blend
37
35
  // DECISION stays the consumer's, by choosing which pool to call.
38
36
  export { default as Pool } from "./Pool.ts";
39
- export { chatCompletionStream, chatCompletion, OpenAiHttpError, StreamIdleError } from "./openaiStream.ts";
40
- export type { StreamResponse, EncryptedReasoningItem, ProviderFetch } from "./openaiStream.ts";
37
+ export type { ProviderFetch } from "./AiSdkProvider.ts";
41
38
  export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, envelopeFromEnv, resolveReserve } from "./env.ts";
42
39
  export type { Reasoning, ReasoningMode, ReserveSpec } from "./env.ts";
43
40
  export { normalizeUsage, calculateCostUsd } from "./usage.ts";
44
41
  export type { RawUsage, TokenRates } from "./usage.ts";
45
42
  export { ProviderError, classifyProviderError, toProviderError, providerSource } from "./telemetry.ts";
46
43
  export type { TelemetryEvent, ProviderTelemetryKind } from "./telemetry.ts";
47
- export { STANDARD_PROVIDERS, isStandardProvider, standardProviderFromEnv } from "./standardProviders.ts";
48
44
 
49
45
  export { default as Mock } from "./Mock.ts";
50
46
  export type { MockAssistant, MockResponse, MockReturnedAssistant } from "./Mock.ts";
package/src/ollama.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
2
+ import { contextWindowFromEnv, parseRequiredInt, requireEnv } from "./env.ts";
3
+ import { providerFromSdkModel } from "./catalogProvider.ts";
4
+ import type { Provider, ProviderOptions } from "./types.ts";
5
+
6
+ type ShowResponse = {
7
+ model_info?: Record<string, unknown>;
8
+ };
9
+
10
+ const fetchContextWindow = async ({
11
+ baseUrl,
12
+ model,
13
+ timeout,
14
+ }: {
15
+ baseUrl: string;
16
+ model: string;
17
+ timeout: number;
18
+ }): Promise<number> => {
19
+ const response = await fetch(`${baseUrl}/api/show`, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ model }),
23
+ signal: AbortSignal.timeout(timeout),
24
+ });
25
+ if (!response.ok) throw new Error(`ollama provider: /api/show returned ${response.status}`);
26
+ const data = await response.json() as ShowResponse;
27
+ for (const [key, value] of Object.entries(data.model_info ?? {})) {
28
+ if (key.endsWith(".context_length") && typeof value === "number" && value > 0) return value;
29
+ }
30
+ throw new Error(`ollama provider: /api/show has no *.context_length key for "${model}"`);
31
+ };
32
+
33
+ export const ollamaProviderFromEnv = async (
34
+ env: NodeJS.ProcessEnv,
35
+ model: string,
36
+ options?: ProviderOptions,
37
+ ): Promise<Provider> => {
38
+ const configured = options?.baseUrl ?? env.OLLAMA_BASE_URL ?? env.OLLAMA_HOST;
39
+ const baseUrl = requireEnv(
40
+ configured,
41
+ "OLLAMA_BASE_URL or OLLAMA_HOST (or a PLURNK_BASEURL_<alias> override)",
42
+ "ollama",
43
+ ).replace(/\/+$/, "").replace(/\/v1$/, "");
44
+ const timeout = parseRequiredInt(
45
+ env.PLURNK_PROVIDERS_FETCH_TIMEOUT,
46
+ "PLURNK_PROVIDERS_FETCH_TIMEOUT",
47
+ "ollama",
48
+ );
49
+ const contextWindow = contextWindowFromEnv(env, "ollama")
50
+ ?? await fetchContextWindow({ baseUrl, model, timeout });
51
+ const languageModel = createOpenAICompatible({
52
+ name: "ollama",
53
+ baseURL: `${baseUrl}/v1`,
54
+ includeUsage: true,
55
+ }).languageModel(model);
56
+ return providerFromSdkModel({
57
+ name: "ollama",
58
+ env,
59
+ model,
60
+ languageModel,
61
+ contextWindow,
62
+ });
63
+ };
package/src/openai.ts CHANGED
@@ -1,11 +1,5 @@
1
- export { default as OpenAICompatProvider, effortFromBudget } from "./OpenAICompat.ts";
2
- export type { GrammarStyle, OpenAICompatConfig, ReasoningStyle } from "./OpenAICompat.ts";
3
- export { chatCompletion, chatCompletionStream, OpenAiHttpError, StreamIdleError } from "./openaiStream.ts";
4
- export type {
5
- EncryptedReasoningItem,
6
- ProviderFetch,
7
- StreamResponse,
8
- } from "./openaiStream.ts";
1
+ export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
2
+ export type { AiSdkProviderConfig, GrammarStyle, ProviderFetch, ReasoningStyle } from "./AiSdkProvider.ts";
9
3
  export type {
10
4
  ChatMessage,
11
5
  FinishReason,
@@ -0,0 +1,47 @@
1
+ import test from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
4
+
5
+ test("configuredProviderInfo translates one env declaration into provider facts", () => {
6
+ assert.deepEqual(configuredProviderInfo("acme-cloud", {
7
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_NPM: "@ai-sdk/openai-compatible",
8
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_BASE_URL: "https://api.acme.test/v1",
9
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_API_KEY_ENV: "ACME_API_KEY, ACME_TOKEN",
10
+ }), {
11
+ id: "acme-cloud",
12
+ npm: "@ai-sdk/openai-compatible",
13
+ env: ["ACME_API_KEY", "ACME_TOKEN"],
14
+ api: "https://api.acme.test/v1",
15
+ });
16
+ });
17
+
18
+ test("createSdkModel uses Models.dev provider facts and operator credentials", () => {
19
+ const sdk = createSdkModel("xai", "grok-build-0.1", { XAI_API_KEY: "test-key" });
20
+ assert.notEqual(sdk, null);
21
+ assert.equal(sdk?.catalog?.npm, "@ai-sdk/xai");
22
+ const languageModel = sdk?.languageModel as { provider: string; modelId: string };
23
+ assert.equal(languageModel.provider, "xai.responses");
24
+ assert.equal(languageModel.modelId, "grok-build-0.1");
25
+ });
26
+
27
+ test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
28
+ const sdk = createSdkModel("cloudflare", "@cf/google/gemma-4-26b-a4b-it", {
29
+ CLOUDFLARE_ACCOUNT_ID: "account",
30
+ CLOUDFLARE_API_TOKEN: "token",
31
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_API_KEY_ENV: "CLOUDFLARE_API_TOKEN,CLOUDFLARE_API_KEY",
32
+ });
33
+ assert.equal(sdk?.languageModel, undefined);
34
+ assert.deepEqual(sdk?.compatible, {
35
+ url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
36
+ headers: { Authorization: "Bearer token" },
37
+ });
38
+ });
39
+
40
+ test("createSdkModel fails clearly for a declared but unsupported SDK package", () => {
41
+ assert.throws(
42
+ () => createSdkModel("acme", "model", {
43
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@acme/ai-sdk",
44
+ }),
45
+ /Models.dev declares unsupported AI SDK package @acme\/ai-sdk/,
46
+ );
47
+ });
@@ -0,0 +1,194 @@
1
+ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createDeepInfra } from "@ai-sdk/deepinfra";
4
+ import { createGoogle } from "@ai-sdk/google";
5
+ import { createGroq } from "@ai-sdk/groq";
6
+ import { createMistral } from "@ai-sdk/mistral";
7
+ import { createOpenAI } from "@ai-sdk/openai";
8
+ import { createTogetherAI } from "@ai-sdk/togetherai";
9
+ import { createXai } from "@ai-sdk/xai";
10
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
11
+ import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
12
+ import type { LanguageModel } from "ai";
13
+
14
+ export type SdkModel = {
15
+ readonly languageModel?: LanguageModel;
16
+ readonly compatible?: {
17
+ readonly url: string;
18
+ readonly headers: Readonly<Record<string, string>>;
19
+ };
20
+ readonly catalog: ProviderInfo | null;
21
+ };
22
+
23
+ const envPrefix = (provider: string): string =>
24
+ provider.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
25
+
26
+ export const configuredProviderInfo = (
27
+ provider: string,
28
+ env: NodeJS.ProcessEnv,
29
+ ): ProviderInfo | null => {
30
+ const prefix = `PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}`;
31
+ const npm = env[`${prefix}_NPM`];
32
+ if (npm === undefined || npm.length === 0) return null;
33
+ const keyNames = env[`${prefix}_API_KEY_ENV`]
34
+ ?.split(",")
35
+ .map((name) => name.trim())
36
+ .filter(Boolean) ?? [];
37
+ const api = env[`${prefix}_BASE_URL`];
38
+ return {
39
+ id: provider,
40
+ npm,
41
+ env: keyNames,
42
+ ...(api === undefined || api.length === 0 ? {} : { api }),
43
+ };
44
+ };
45
+
46
+ const firstSet = (env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined => {
47
+ for (const name of names) {
48
+ const value = env[name];
49
+ if (value !== undefined && value.length > 0) return value;
50
+ }
51
+ return undefined;
52
+ };
53
+
54
+ const configuredKeyNames = (
55
+ provider: string,
56
+ env: NodeJS.ProcessEnv,
57
+ catalog: ProviderInfo,
58
+ ): readonly string[] => {
59
+ const configured = env[`PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}_API_KEY_ENV`];
60
+ return configured === undefined || configured.length === 0
61
+ ? catalog.env
62
+ : configured.split(",").map((name) => name.trim()).filter(Boolean);
63
+ };
64
+
65
+ const expandEnv = (value: string, env: NodeJS.ProcessEnv, provider: string): string =>
66
+ value.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, name: string) => {
67
+ const replacement = env[name];
68
+ if (replacement === undefined || replacement.length === 0) {
69
+ throw new Error(`${provider} provider: ${name} must be set to resolve the Models.dev API URL`);
70
+ }
71
+ return replacement;
72
+ });
73
+
74
+ const baseUrl = (
75
+ provider: string,
76
+ env: NodeJS.ProcessEnv,
77
+ catalog: ProviderInfo,
78
+ override?: string,
79
+ ): string | undefined => {
80
+ const prefix = envPrefix(provider);
81
+ const configured = env[`PLURNK_PROVIDERS_PROVIDER_${prefix}_BASE_URL`]
82
+ ?? env[`${prefix}_BASE_URL`]
83
+ ?? catalog.api;
84
+ const value = override ?? configured;
85
+ return value === undefined ? undefined : expandEnv(value, env, provider).replace(/\/+$/, "");
86
+ };
87
+
88
+ const apiKey = (
89
+ provider: string,
90
+ env: NodeJS.ProcessEnv,
91
+ catalog: ProviderInfo,
92
+ ): string | undefined => firstSet(env, configuredKeyNames(provider, env, catalog));
93
+
94
+ const requireApiKey = (
95
+ provider: string,
96
+ env: NodeJS.ProcessEnv,
97
+ catalog: ProviderInfo,
98
+ ): string => {
99
+ const names = configuredKeyNames(provider, env, catalog);
100
+ const key = firstSet(env, names);
101
+ if (key === undefined) throw new Error(`${provider} provider: ${names.join(" or ")} must be set`);
102
+ return key;
103
+ };
104
+
105
+ export const createSdkModel = (
106
+ provider: string,
107
+ model: string,
108
+ env: NodeJS.ProcessEnv,
109
+ baseUrlOverride?: string,
110
+ ): SdkModel | null => {
111
+ const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
112
+ if (catalog === null) return null;
113
+ const url = baseUrl(provider, env, catalog, baseUrlOverride);
114
+
115
+ switch (catalog.npm) {
116
+ case "@ai-sdk/openai":
117
+ return {
118
+ languageModel: createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).chat(model),
119
+ catalog,
120
+ };
121
+ case "@ai-sdk/groq":
122
+ return {
123
+ languageModel: createGroq({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
124
+ catalog,
125
+ };
126
+ case "@ai-sdk/mistral":
127
+ return {
128
+ languageModel: createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
129
+ catalog,
130
+ };
131
+ case "@ai-sdk/togetherai":
132
+ return {
133
+ languageModel: createTogetherAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
134
+ catalog,
135
+ };
136
+ case "@ai-sdk/deepinfra":
137
+ return {
138
+ languageModel: createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
139
+ catalog,
140
+ };
141
+ case "@ai-sdk/google":
142
+ return {
143
+ languageModel: createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
144
+ catalog,
145
+ };
146
+ case "@ai-sdk/xai":
147
+ return {
148
+ languageModel: createXai({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
149
+ catalog,
150
+ };
151
+ case "@ai-sdk/anthropic":
152
+ return {
153
+ languageModel: createAnthropic({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
154
+ catalog,
155
+ };
156
+ case "@ai-sdk/amazon-bedrock":
157
+ return {
158
+ languageModel: createAmazonBedrock({
159
+ region: env.AWS_REGION ?? env.AWS_DEFAULT_REGION,
160
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
161
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
162
+ sessionToken: env.AWS_SESSION_TOKEN,
163
+ apiKey: env.AWS_BEARER_TOKEN_BEDROCK,
164
+ baseURL: url,
165
+ }).languageModel(model),
166
+ catalog,
167
+ };
168
+ case "@openrouter/ai-sdk-provider":
169
+ return {
170
+ languageModel: createOpenRouter({
171
+ apiKey: requireApiKey(provider, env, catalog),
172
+ baseURL: url,
173
+ headers: {
174
+ ...(env.OPENROUTER_HTTP_REFERER === undefined ? {} : { "HTTP-Referer": env.OPENROUTER_HTTP_REFERER }),
175
+ ...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
176
+ },
177
+ }).languageModel(model),
178
+ catalog,
179
+ };
180
+ case "@ai-sdk/openai-compatible":
181
+ if (url === undefined) throw new Error(`${provider} provider: Models.dev supplies no API URL and no base URL was configured`);
182
+ return {
183
+ compatible: {
184
+ url: `${url}/chat/completions`,
185
+ headers: apiKey(provider, env, catalog) === undefined
186
+ ? {}
187
+ : { Authorization: `Bearer ${apiKey(provider, env, catalog)}` },
188
+ },
189
+ catalog,
190
+ };
191
+ default:
192
+ throw new Error(`${provider} provider: Models.dev declares unsupported AI SDK package ${catalog.npm}`);
193
+ }
194
+ };
@@ -1,7 +1,15 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
+ import { APICallError } from "ai";
3
4
  import { ProviderError, classifyProviderError, toProviderError, providerSource } from "./telemetry.ts";
4
- import { OpenAiHttpError } from "./openaiStream.ts";
5
+
6
+ const apiError = (statusCode: number, responseBody = "body") => new APICallError({
7
+ message: `request failed (${statusCode})`,
8
+ url: "https://example.test/v1/chat/completions",
9
+ requestBodyValues: {},
10
+ statusCode,
11
+ responseBody,
12
+ });
5
13
 
6
14
  // Schema pattern for TelemetryEvent.source (from grammar's TelemetryEvent.json).
7
15
  const SOURCE_PATTERN = /^[a-z]+(:[a-z][a-z0-9-]*)?$/;
@@ -13,7 +21,7 @@ test("providerSource produces a schema-valid colon-namespaced source", () => {
13
21
  });
14
22
 
15
23
  test("classifyProviderError maps HTTP status to kind", () => {
16
- const k = (status: number) => classifyProviderError(new OpenAiHttpError(status, "body", null)).kind;
24
+ const k = (status: number) => classifyProviderError(apiError(status)).kind;
17
25
  assert.equal(k(401), "unauthorized");
18
26
  assert.equal(k(403), "unauthorized");
19
27
  assert.equal(k(402), "quota_exceeded");
@@ -24,13 +32,11 @@ test("classifyProviderError maps HTTP status to kind", () => {
24
32
  assert.equal(k(404), "invalid_response");
25
33
  });
26
34
 
27
- test("classifyProviderError: a 422 flagged grammar_invalid is transient (#548); other 422s terminal", () => {
28
- const rejected = new OpenAiHttpError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }), null);
35
+ test("classifyProviderError: a 422 flagged grammar_invalid is distinct (#548); other 422s are invalid responses", () => {
36
+ const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
29
37
  assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
30
- // a 422 carrying any other error.type is a malformed request — terminal
31
- assert.equal(classifyProviderError(new OpenAiHttpError(422, JSON.stringify({ error: { type: "invalid_request_error" } }), null)).kind, "invalid_response");
32
- // a non-JSON 422 body (edge/proxy HTML) is terminal, and the sniff does not throw
33
- assert.equal(classifyProviderError(new OpenAiHttpError(422, "<html>Bad</html>", null)).kind, "invalid_response");
38
+ assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "invalid_response");
39
+ assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "invalid_response");
34
40
  });
35
41
 
36
42
  test("classifyProviderError treats non-HTTP errors as network_failure", () => {
@@ -49,11 +55,12 @@ test("ProviderError.toTelemetryEvent emits the canonical envelope", () => {
49
55
  });
50
56
 
51
57
  test("toProviderError classifies and tags an HTTP error with the source + status", () => {
52
- const pe = toProviderError(new OpenAiHttpError(401, "no key", null), "provider:groq");
58
+ const cause = apiError(401, "no key");
59
+ const pe = toProviderError(cause, "provider:groq");
53
60
  assert.equal(pe.kind, "unauthorized");
54
61
  assert.equal(pe.source, "provider:groq");
55
62
  assert.equal(pe.status, 401);
56
- assert.equal(pe.cause instanceof OpenAiHttpError, true); // original preserved
63
+ assert.equal(pe.cause, cause);
57
64
  });
58
65
 
59
66
  test("toProviderError passes an existing ProviderError through unchanged", () => {
package/src/telemetry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // dependency on grammar (see SPEC §11). Consumers route provider events through
7
7
  // the same `source` + `kind` discriminator as parse/rail events.
8
8
 
9
- import { OpenAiHttpError } from "./openaiStream.ts";
9
+ import { APICallError, RetryError } from "ai";
10
10
 
11
11
  // Required by the schema: source (producer id) + kind (discriminator). message
12
12
  // and position are optional. A transport failure isn't localizable, so it carries
@@ -29,11 +29,9 @@ export type ProviderTelemetryKind =
29
29
  | "invalid_response"
30
30
  | "unauthorized"
31
31
  | "quota_exceeded"
32
- // A 422 whose error.type is "grammar_invalid" (#548): the backend served ONE
33
- // generation and rejected it as non-conforming. Transient, not terminal — a
34
- // fresh sample may conform — so it rides the retry budget. Distinct from
35
- // grammar_unenforced below: that observes a SUCCEEDED exchange; this is a
36
- // thrown transport failure the daemon retries, then fails as a leaf.
32
+ // A 422 whose error.type is "grammar_invalid" (#548): the backend served one
33
+ // generation and rejected it as non-conforming. This identifies the failure;
34
+ // whether to start another model exchange belongs to the engine.
37
35
  | "grammar_invalid"
38
36
  // Output did not conform to the GBNF. ALWAYS an observation, never a throw:
39
37
  // a completed exchange returns its bytes with this event on
@@ -80,18 +78,27 @@ const wireErrorType = (body: string): string | null => {
80
78
  // Map a thrown transport error to a (kind, message). Conservative; the message
81
79
  // is factual, no guidance prose (consumer SPEC §15.1 policy).
82
80
  export const classifyProviderError = (err: unknown): { kind: ProviderTelemetryKind; message: string } => {
83
- if (err instanceof OpenAiHttpError) {
84
- const { status, message } = err;
81
+ if (RetryError.isInstance(err)) return classifyProviderError(err.lastError);
82
+ if (APICallError.isInstance(err)) {
83
+ const status = err.statusCode ?? 0;
84
+ const message = err.message;
85
+ const body = err.responseBody ?? "";
85
86
  if (status === 401 || status === 403) return { kind: "unauthorized", message };
86
87
  if (status === 402) return { kind: "quota_exceeded", message };
87
88
  if (status === 429) return { kind: "rate_limit", message };
88
89
  if (status >= 500) return { kind: "network_failure", message };
89
- // A 422 flagged grammar_invalid is a stochastic output reject (#548) — one
90
- // generation served then rejected, so a fresh sample may conform: transient.
91
- // Any other 422 is a malformed request: terminal (invalid_response).
92
- if (status === 422 && wireErrorType(err.body) === "grammar_invalid") return { kind: "grammar_invalid", message };
90
+ // A flagged 422 identifies a grammar-rejected exchange. It is not
91
+ // transport replay policy; the engine decides whether to sample again.
92
+ if (status === 422 && wireErrorType(body) === "grammar_invalid") return { kind: "grammar_invalid", message };
93
93
  return { kind: "invalid_response", message };
94
94
  }
95
+ const wire = err as { message?: unknown; type?: unknown; status?: unknown };
96
+ if (wire?.type === "grammar_invalid") {
97
+ return {
98
+ kind: "grammar_invalid",
99
+ message: typeof wire.message === "string" ? wire.message : "grammar-invalid response",
100
+ };
101
+ }
95
102
  const e = err as { name?: string; message?: string };
96
103
  const message = (e?.message ?? String(err)) || "request failed";
97
104
  // Timeouts / fetch-level failures are transport, not a usable response.
@@ -102,7 +109,8 @@ export const classifyProviderError = (err: unknown): { kind: ProviderTelemetryKi
102
109
  // Already-classified errors pass through unchanged.
103
110
  export const toProviderError = (err: unknown, source: string): ProviderError => {
104
111
  if (err instanceof ProviderError) return err;
105
- const { kind, message } = classifyProviderError(err);
106
- const status = err instanceof OpenAiHttpError ? err.status : null;
112
+ const underlying = RetryError.isInstance(err) ? err.lastError : err;
113
+ const { kind, message } = classifyProviderError(underlying);
114
+ const status = APICallError.isInstance(underlying) ? underlying.statusCode ?? null : null;
107
115
  return new ProviderError(source, kind, message, { status, cause: err });
108
116
  };
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // is the wire-reported CoT only.
4
4
 
5
5
  import type { TelemetryEvent } from "./telemetry.ts";
6
+ import type { LanguageModel } from "ai";
6
7
 
7
8
  export interface ChatMessage {
8
9
  role: "system" | "user" | "assistant";
@@ -208,12 +209,8 @@ export interface ProviderOptions {
208
209
  readonly baseUrl?: string;
209
210
  }
210
211
 
211
- // Each provider package's default export MUST be a factory:
212
- // static fromEnv(env, model, options?) → Provider | Promise<Provider>
213
- // `model` is the second positional arg because PLURNK_MODEL_<alias>=<provider>/<model>
214
- // is parsed by the registry; the resolved model id flows through. `options` is an
215
- // optional third arg (per-alias overrides, e.g. baseUrl); a factory that ignores
216
- // it keeps working unchanged.
217
- export interface ProviderFactory {
218
- fromEnv(env: NodeJS.ProcessEnv, model: string, options?: ProviderOptions): Provider | Promise<Provider>;
212
+ // A discovered provider plugin default-exports an AI SDK provider. PLURNK owns
213
+ // the adapter into Provider; the plugin owns only its protocol binding.
214
+ export interface AiSdkProviderPlugin {
215
+ languageModel(model: string): LanguageModel;
219
216
  }