@oscharko-dev/keiko-model-gateway 0.2.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.
Files changed (116) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/capabilities.d.ts +26 -0
  3. package/dist/capabilities.d.ts.map +1 -0
  4. package/dist/capabilities.data.d.ts +3 -0
  5. package/dist/capabilities.data.d.ts.map +1 -0
  6. package/dist/capabilities.data.js +5 -0
  7. package/dist/capabilities.js +169 -0
  8. package/dist/config.d.ts +34 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +733 -0
  11. package/dist/embedding.d.ts +38 -0
  12. package/dist/embedding.d.ts.map +1 -0
  13. package/dist/embedding.js +118 -0
  14. package/dist/gateway.d.ts +23 -0
  15. package/dist/gateway.d.ts.map +1 -0
  16. package/dist/gateway.js +144 -0
  17. package/dist/http.d.ts +24 -0
  18. package/dist/http.d.ts.map +1 -0
  19. package/dist/http.js +666 -0
  20. package/dist/index.d.ts +16 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +34 -0
  23. package/dist/model-selection.d.ts +22 -0
  24. package/dist/model-selection.d.ts.map +1 -0
  25. package/dist/model-selection.js +59 -0
  26. package/dist/normalize.d.ts +9 -0
  27. package/dist/normalize.d.ts.map +1 -0
  28. package/dist/normalize.js +114 -0
  29. package/dist/openai-adapter.d.ts +22 -0
  30. package/dist/openai-adapter.d.ts.map +1 -0
  31. package/dist/openai-adapter.js +382 -0
  32. package/dist/openai-embedding-adapter.d.ts +46 -0
  33. package/dist/openai-embedding-adapter.d.ts.map +1 -0
  34. package/dist/openai-embedding-adapter.js +271 -0
  35. package/dist/promptEnhancer/__tests__/_support.d.ts +15 -0
  36. package/dist/promptEnhancer/__tests__/_support.d.ts.map +1 -0
  37. package/dist/promptEnhancer/__tests__/_support.js +28 -0
  38. package/dist/promptEnhancer/__tests__/fixtures.d.ts +8 -0
  39. package/dist/promptEnhancer/__tests__/fixtures.d.ts.map +1 -0
  40. package/dist/promptEnhancer/__tests__/fixtures.js +58 -0
  41. package/dist/promptEnhancer/__tests__/grounding-fixtures.d.ts +11 -0
  42. package/dist/promptEnhancer/__tests__/grounding-fixtures.d.ts.map +1 -0
  43. package/dist/promptEnhancer/__tests__/grounding-fixtures.js +84 -0
  44. package/dist/promptEnhancer/candidates.d.ts +32 -0
  45. package/dist/promptEnhancer/candidates.d.ts.map +1 -0
  46. package/dist/promptEnhancer/candidates.js +109 -0
  47. package/dist/promptEnhancer/critic.d.ts +22 -0
  48. package/dist/promptEnhancer/critic.d.ts.map +1 -0
  49. package/dist/promptEnhancer/critic.js +237 -0
  50. package/dist/promptEnhancer/generator.d.ts +15 -0
  51. package/dist/promptEnhancer/generator.d.ts.map +1 -0
  52. package/dist/promptEnhancer/generator.js +424 -0
  53. package/dist/promptEnhancer/index.d.ts +16 -0
  54. package/dist/promptEnhancer/index.d.ts.map +1 -0
  55. package/dist/promptEnhancer/index.js +15 -0
  56. package/dist/promptEnhancer/optimize.d.ts +27 -0
  57. package/dist/promptEnhancer/optimize.d.ts.map +1 -0
  58. package/dist/promptEnhancer/optimize.js +203 -0
  59. package/dist/promptEnhancer/planner.d.ts +36 -0
  60. package/dist/promptEnhancer/planner.d.ts.map +1 -0
  61. package/dist/promptEnhancer/planner.js +55 -0
  62. package/dist/promptEnhancer/profiles.d.ts +20 -0
  63. package/dist/promptEnhancer/profiles.d.ts.map +1 -0
  64. package/dist/promptEnhancer/profiles.js +126 -0
  65. package/dist/promptEnhancer/rendering.d.ts +15 -0
  66. package/dist/promptEnhancer/rendering.d.ts.map +1 -0
  67. package/dist/promptEnhancer/rendering.js +72 -0
  68. package/dist/promptEnhancer/validate.d.ts +31 -0
  69. package/dist/promptEnhancer/validate.d.ts.map +1 -0
  70. package/dist/promptEnhancer/validate.js +144 -0
  71. package/dist/qualityIntelligence/budget.d.ts +10 -0
  72. package/dist/qualityIntelligence/budget.d.ts.map +1 -0
  73. package/dist/qualityIntelligence/budget.js +38 -0
  74. package/dist/qualityIntelligence/cancellation.d.ts +7 -0
  75. package/dist/qualityIntelligence/cancellation.d.ts.map +1 -0
  76. package/dist/qualityIntelligence/cancellation.js +58 -0
  77. package/dist/qualityIntelligence/capabilityGate.d.ts +13 -0
  78. package/dist/qualityIntelligence/capabilityGate.d.ts.map +1 -0
  79. package/dist/qualityIntelligence/capabilityGate.js +51 -0
  80. package/dist/qualityIntelligence/capabilityMapping.d.ts +4 -0
  81. package/dist/qualityIntelligence/capabilityMapping.d.ts.map +1 -0
  82. package/dist/qualityIntelligence/capabilityMapping.js +21 -0
  83. package/dist/qualityIntelligence/circuitBreaker.d.ts +26 -0
  84. package/dist/qualityIntelligence/circuitBreaker.d.ts.map +1 -0
  85. package/dist/qualityIntelligence/circuitBreaker.js +78 -0
  86. package/dist/qualityIntelligence/dispatcher.d.ts +38 -0
  87. package/dist/qualityIntelligence/dispatcher.d.ts.map +1 -0
  88. package/dist/qualityIntelligence/dispatcher.js +116 -0
  89. package/dist/qualityIntelligence/index.d.ts +20 -0
  90. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  91. package/dist/qualityIntelligence/index.js +15 -0
  92. package/dist/qualityIntelligence/promptSegmentation.d.ts +13 -0
  93. package/dist/qualityIntelligence/promptSegmentation.d.ts.map +1 -0
  94. package/dist/qualityIntelligence/promptSegmentation.js +70 -0
  95. package/dist/qualityIntelligence/replayCache.d.ts +11 -0
  96. package/dist/qualityIntelligence/replayCache.d.ts.map +1 -0
  97. package/dist/qualityIntelligence/replayCache.js +72 -0
  98. package/dist/qualityIntelligence/routing.d.ts +11 -0
  99. package/dist/qualityIntelligence/routing.d.ts.map +1 -0
  100. package/dist/qualityIntelligence/routing.js +25 -0
  101. package/dist/qualityIntelligence/safeError.d.ts +38 -0
  102. package/dist/qualityIntelligence/safeError.d.ts.map +1 -0
  103. package/dist/qualityIntelligence/safeError.js +63 -0
  104. package/dist/qualityIntelligence/taskProfiles.d.ts +15 -0
  105. package/dist/qualityIntelligence/taskProfiles.d.ts.map +1 -0
  106. package/dist/qualityIntelligence/taskProfiles.js +101 -0
  107. package/dist/resilience.d.ts +26 -0
  108. package/dist/resilience.d.ts.map +1 -0
  109. package/dist/resilience.js +182 -0
  110. package/dist/types.d.ts +59 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +6 -0
  113. package/dist/version.d.ts +2 -0
  114. package/dist/version.d.ts.map +1 -0
  115. package/dist/version.js +3 -0
  116. package/package.json +47 -0
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ // Public barrel for the model gateway: wire/config types, the Gateway orchestrator,
2
+ // capability helpers, config loaders, model selection, and the typed error taxonomy.
3
+ // Low-level provider adapters, HTTP transport, response normalization, and retry primitives
4
+ // for PRODUCTIVE chat calls are intentionally kept off this surface so productive calls
5
+ // cannot bypass Gateway routing.
6
+ //
7
+ // Carve-out (#192): the OpenAI-compatible embeddings transport (`requestOpenAIEmbedding`)
8
+ // IS exported as the default `OpenAIEmbeddingAdapter.request` implementation. This is the
9
+ // `OpenAIEmbeddingAdapter` injection port for `verifyEmbeddingCapability` — an out-of-band
10
+ // capability probe, not a productive model call. Productive embedding flows still compose
11
+ // the adapter behind the Local Knowledge Connector orchestrator (#196), so the Gateway-
12
+ // routing invariant is preserved.
13
+ export { KEIKO_MODEL_GATEWAY_VERSION } from "./version.js";
14
+ export { CAPABILITY_REGISTRY, createDefaultChatCapability, explainConversationIneligibility, findCapability, INFILLING_ALIGNMENTS, isAlignedInfillingModel, isAsYouTypeCompletionModel, isConversationEligibleModel, listCapabilities, modelSupportsInfilling, resolveCostClass, selectCheapest, selectCompletionModelFromCapabilities, } from "./capabilities.js";
15
+ export { CAPABILITY_DATA } from "./capabilities.data.js";
16
+ export { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME, loadConfigFromFile, loadEgressConfigFromFile, normalizeApiKeyHeaderName, parseGatewayConfig, resolveOutboundHttpEgressConfig, toSafeObject, validateBaseUrl, } from "./config.js";
17
+ export { Gateway } from "./gateway.js";
18
+ export { assertConfiguredModel, findConfiguredCapability, listConfiguredCapabilities, selectCompletionModel, selectConfiguredModel, } from "./model-selection.js";
19
+ export { assertCompatibleEmbeddingIdentity, verifyEmbeddingCapability, } from "./embedding.js";
20
+ export { requestOpenAIEmbedding, requestOpenAIEmbeddingBatch, } from "./openai-embedding-adapter.js";
21
+ export { redact } from "@oscharko-dev/keiko-security";
22
+ export { AuthenticationError, CancelledError, CircuitOpenError, ConfigInvalidError, ContextOverflowError, ERROR_CODES, GatewayEgressError, GatewayError, MalformedToolCallError, ModelRefusalError, ProviderError, RateLimitError, TimeoutError, TransportError, UnknownModelError, } from "@oscharko-dev/keiko-security/errors/gateway";
23
+ // Quality Intelligence sub-module (Epic #270, Issue #279). Exposed under a namespace so
24
+ // callers reach typed task profiles, the prompt-segmentation seam, the capability gate,
25
+ // the safe-error taxonomy, and (post-M3) the dispatcher via a single import surface.
26
+ export * as QualityIntelligence from "./qualityIntelligence/index.js";
27
+ // Flat re-exports of the QI dispatcher surface so downstream orchestration packages
28
+ // (Issue #273 keiko-workflows runners) avoid namespace plumbing on hot paths.
29
+ export { QualityIntelligenceSafeErrorException, createInMemoryReplayCache, deriveReplayCacheKey, dispatchQualityIntelligenceRequest, isCacheable, } from "./qualityIntelligence/index.js";
30
+ // Prompt Enhancer sub-module (Epic #1307, Issue #1310; ADR-0044 §1). Exposed under a namespace,
31
+ // mirroring Quality Intelligence, so callers reach the generation-profile execution catalog, the
32
+ // deterministic planner, the structured generator, and the provider-neutral renderers. Model-bound
33
+ // candidate/critic dispatch is added by #1312.
34
+ export * as PromptEnhancer from "./promptEnhancer/index.js";
@@ -0,0 +1,22 @@
1
+ import { type CompletionSelectionOptions } from "./capabilities.js";
2
+ import type { CompletionModelSelection, GatewayConfig, ModelCapability, ModelKind } from "./types.js";
3
+ export interface ModelSelectionQuery {
4
+ readonly kind: ModelKind;
5
+ readonly toolCalling?: boolean | undefined;
6
+ readonly structuredOutput?: boolean | undefined;
7
+ readonly minContextWindow?: number | undefined;
8
+ readonly supportsImageInput?: boolean | undefined;
9
+ }
10
+ export interface ConfiguredCapabilityProvider {
11
+ readonly modelId: string;
12
+ }
13
+ export interface ConfiguredCapabilitySource {
14
+ readonly providers: readonly ConfiguredCapabilityProvider[];
15
+ readonly capabilities?: readonly ModelCapability[] | undefined;
16
+ }
17
+ export declare function assertConfiguredModel(config: ConfiguredCapabilitySource, modelId: string): void;
18
+ export declare function findConfiguredCapability(config: ConfiguredCapabilitySource, modelId: string): ModelCapability | undefined;
19
+ export declare function listConfiguredCapabilities(config: ConfiguredCapabilitySource): readonly ModelCapability[];
20
+ export declare function selectConfiguredModel(config: ConfiguredCapabilitySource, query: ModelSelectionQuery): string | undefined;
21
+ export declare function selectCompletionModel(config: GatewayConfig, options?: CompletionSelectionOptions): CompletionModelSelection;
22
+ //# sourceMappingURL=model-selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-selection.d.ts","sourceRoot":"","sources":["../src/model-selection.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,0BAA0B,EAChC,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EACV,wBAAwB,EACxB,aAAa,EACb,eAAe,EACf,SAAS,EACV,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAG/C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACnD;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,SAAS,EAAE,SAAS,4BAA4B,EAAE,CAAC;IAC5D,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,GAAG,SAAS,CAAC;CAChE;AAqBD,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,0BAA0B,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI/F;AAED,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,0BAA0B,EAClC,OAAO,EAAE,MAAM,GACd,eAAe,GAAG,SAAS,CAU7B;AAED,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,0BAA0B,GACjC,SAAS,eAAe,EAAE,CAI5B;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,0BAA0B,EAClC,KAAK,EAAE,mBAAmB,GACzB,MAAM,GAAG,SAAS,CAWpB;AAMD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,aAAa,EACrB,OAAO,GAAE,0BAA+B,GACvC,wBAAwB,CAE1B"}
@@ -0,0 +1,59 @@
1
+ import { createDefaultChatCapability, createDefaultEmbeddingCapability, isLikelyEmbeddingModelId, listCapabilities, selectCompletionModelFromCapabilities, } from "./capabilities.js";
2
+ import { ConfigInvalidError } from "@oscharko-dev/keiko-security/errors/gateway";
3
+ const COST_RANK = { low: 0, medium: 1, high: 2 };
4
+ function matches(capability, query) {
5
+ if (capability.kind !== query.kind) {
6
+ return false;
7
+ }
8
+ if (query.toolCalling === true && !capability.toolCalling) {
9
+ return false;
10
+ }
11
+ if (query.structuredOutput === true && !capability.structuredOutput) {
12
+ return false;
13
+ }
14
+ if (query.supportsImageInput === true && !capability.supportsImageInput) {
15
+ return false;
16
+ }
17
+ if (query.minContextWindow !== undefined && capability.contextWindow < query.minContextWindow) {
18
+ return false;
19
+ }
20
+ return true;
21
+ }
22
+ export function assertConfiguredModel(config, modelId) {
23
+ if (!config.providers.some((provider) => provider.modelId === modelId)) {
24
+ throw new ConfigInvalidError(`model '${modelId}' is not configured as a provider`);
25
+ }
26
+ }
27
+ export function findConfiguredCapability(config, modelId) {
28
+ return (config.capabilities?.find((capability) => capability.id === modelId) ??
29
+ listCapabilities().find((capability) => capability.id === modelId) ??
30
+ (config.providers.some((provider) => provider.modelId === modelId)
31
+ ? isLikelyEmbeddingModelId(modelId)
32
+ ? createDefaultEmbeddingCapability(modelId)
33
+ : createDefaultChatCapability(modelId)
34
+ : undefined));
35
+ }
36
+ export function listConfiguredCapabilities(config) {
37
+ return config.providers
38
+ .map((provider) => findConfiguredCapability(config, provider.modelId))
39
+ .filter((capability) => capability !== undefined);
40
+ }
41
+ export function selectConfiguredModel(config, query) {
42
+ let best;
43
+ for (const capability of listConfiguredCapabilities(config)) {
44
+ if (!matches(capability, query)) {
45
+ continue;
46
+ }
47
+ if (best === undefined || COST_RANK[capability.costClass] < COST_RANK[best.costClass]) {
48
+ best = capability;
49
+ }
50
+ }
51
+ return best?.id;
52
+ }
53
+ // Completion-oriented model selection (Issue #1210, ADR-0042 D5). Resolves the configured
54
+ // capabilities and applies the infilling-aware decision tree (as-you-type → manual → deterministic)
55
+ // from `selectCompletionModelFromCapabilities`. Only configured providers are eligible, so a
56
+ // capability that names no provider can never be elected. The result is content-free.
57
+ export function selectCompletionModel(config, options = {}) {
58
+ return selectCompletionModelFromCapabilities(listConfiguredCapabilities(config), options);
59
+ }
@@ -0,0 +1,9 @@
1
+ import type { CostClass, NormalizedResponse } from "./types.js";
2
+ export interface UsageSeed {
3
+ readonly requestId: string;
4
+ readonly latencyMs: number;
5
+ readonly costClass: CostClass;
6
+ }
7
+ export declare function textFromContent(value: unknown): string;
8
+ export declare function normalizeChatResponse(rawPayload: unknown, modelId: string, seed: UsageSeed, expectStructured?: boolean): NormalizedResponse;
9
+ //# sourceMappingURL=normalize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../src/normalize.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,SAAS,EAET,kBAAkB,EAGnB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;CAC/B;AAqGD,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAQtD;AAED,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,OAAO,EACnB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,EACf,gBAAgB,UAAQ,GACvB,kBAAkB,CAYpB"}
@@ -0,0 +1,114 @@
1
+ // Provider payload → NormalizedResponse. The internal contract is strict and small
2
+ // so workflows fail closed when a provider response is unsafe or malformed.
3
+ import { MalformedToolCallError, ModelRefusalError, } from "@oscharko-dev/keiko-security/errors/gateway";
4
+ const FINISH_REASONS = new Set([
5
+ "stop",
6
+ "tool_calls",
7
+ "length",
8
+ "content_filter",
9
+ "error",
10
+ "cancelled",
11
+ ]);
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function asCount(value) {
16
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
17
+ }
18
+ function mapFinishReason(value) {
19
+ return typeof value === "string" && FINISH_REASONS.has(value)
20
+ ? value
21
+ : "stop";
22
+ }
23
+ function buildUsage(payload, seed) {
24
+ const usage = isRecord(payload.usage) ? payload.usage : {};
25
+ return {
26
+ requestId: seed.requestId,
27
+ promptTokens: asCount(usage.prompt_tokens),
28
+ completionTokens: asCount(usage.completion_tokens),
29
+ latencyMs: seed.latencyMs,
30
+ costClass: seed.costClass,
31
+ };
32
+ }
33
+ function parseToolCall(raw) {
34
+ if (!isRecord(raw) || !isRecord(raw.function)) {
35
+ throw new MalformedToolCallError("tool call is missing a function descriptor");
36
+ }
37
+ const fn = raw.function;
38
+ const name = typeof fn.name === "string" ? fn.name : "";
39
+ const id = typeof raw.id === "string" ? raw.id : "";
40
+ const argsText = typeof fn.arguments === "string" ? fn.arguments : "{}";
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(argsText);
44
+ }
45
+ catch {
46
+ throw new MalformedToolCallError(`tool call '${name}' has non-JSON arguments`);
47
+ }
48
+ if (!isRecord(parsed)) {
49
+ throw new MalformedToolCallError(`tool call '${name}' arguments are not an object`);
50
+ }
51
+ return { id, name, arguments: parsed };
52
+ }
53
+ function parseToolCalls(message) {
54
+ const raw = message.tool_calls;
55
+ return Array.isArray(raw) ? raw.map(parseToolCall) : [];
56
+ }
57
+ function parseStructuredOutput(content) {
58
+ try {
59
+ const parsed = JSON.parse(content);
60
+ return isRecord(parsed) ? parsed : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function assertNotRefusal(message, finishReason) {
67
+ if (finishReason === "content_filter") {
68
+ throw new ModelRefusalError("provider filtered the model response");
69
+ }
70
+ const refusal = message.refusal;
71
+ if (typeof refusal === "string" && refusal.length > 0) {
72
+ throw new ModelRefusalError("provider refused the model request");
73
+ }
74
+ }
75
+ function firstChoice(payload) {
76
+ const choices = payload.choices;
77
+ if (!Array.isArray(choices) || choices.length === 0) {
78
+ return undefined;
79
+ }
80
+ return isRecord(choices[0]) ? choices[0] : undefined;
81
+ }
82
+ function textPart(value) {
83
+ if (typeof value === "string") {
84
+ return value;
85
+ }
86
+ if (!isRecord(value)) {
87
+ return "";
88
+ }
89
+ if (typeof value.text === "string") {
90
+ return value.text;
91
+ }
92
+ return typeof value.content === "string" ? value.content : "";
93
+ }
94
+ export function textFromContent(value) {
95
+ if (typeof value === "string") {
96
+ return value;
97
+ }
98
+ if (!Array.isArray(value)) {
99
+ return "";
100
+ }
101
+ return value.map(textPart).join("");
102
+ }
103
+ export function normalizeChatResponse(rawPayload, modelId, seed, expectStructured = false) {
104
+ const payload = isRecord(rawPayload) ? rawPayload : {};
105
+ const usage = buildUsage(payload, seed);
106
+ const choice = firstChoice(payload);
107
+ const message = choice !== undefined && isRecord(choice.message) ? choice.message : {};
108
+ const finishReason = mapFinishReason(choice?.finish_reason);
109
+ assertNotRefusal(message, finishReason);
110
+ const toolCalls = parseToolCalls(message);
111
+ const content = textFromContent(message.content);
112
+ const structuredOutput = expectStructured && content.length > 0 ? parseStructuredOutput(content) : null;
113
+ return { modelId, content, finishReason, toolCalls, structuredOutput, usage };
114
+ }
@@ -0,0 +1,22 @@
1
+ import type { CostClass, GatewayRequest, GatewayStreamChunk, ModelProviderConfig, NormalizedResponse, ProviderAdapter } from "./types.js";
2
+ export interface AdapterDeps {
3
+ readonly fetchImpl?: typeof fetch | undefined;
4
+ readonly requestId: string;
5
+ readonly costClass: CostClass;
6
+ readonly now?: (() => number) | undefined;
7
+ }
8
+ export declare class OpenAiAdapter implements ProviderAdapter {
9
+ private readonly deps;
10
+ private readonly now;
11
+ constructor(deps: AdapterDeps);
12
+ call: (request: GatewayRequest, config: ModelProviderConfig) => Promise<NormalizedResponse>;
13
+ callStream: (this: OpenAiAdapter, request: GatewayRequest, config: ModelProviderConfig) => AsyncGenerator<GatewayStreamChunk>;
14
+ private streamDeltas;
15
+ private assembleResponse;
16
+ private mapStreamError;
17
+ private dispatch;
18
+ private mapDispatchError;
19
+ private readBody;
20
+ private readErrorBody;
21
+ }
22
+ //# sourceMappingURL=openai-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai-adapter.d.ts","sourceRoot":"","sources":["../src/openai-adapter.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAEV,SAAS,EAET,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAElB,eAAe,EAEhB,MAAM,YAAY,CAAC;AAmBpB,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,KAAK,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAC3C;AA2RD,qBAAa,aAAc,YAAW,eAAe;IAGvC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEN,IAAI,EAAE,WAAW;IAI9C,IAAI,GACF,SAAS,cAAc,EACvB,QAAQ,mBAAmB,KAC1B,OAAO,CAAC,kBAAkB,CAAC,CA2B5B;IAKF,UAAU,GACR,MAAM,aAAa,EACnB,SAAS,cAAc,EACvB,QAAQ,mBAAmB,KAC1B,cAAc,CAAC,kBAAkB,CAAC,CAqBnC;YAUa,YAAY;IA+B3B,OAAO,CAAC,gBAAgB;IAwBxB,OAAO,CAAC,cAAc;YAeR,QAAQ;IA6BtB,OAAO,CAAC,gBAAgB;YAuBV,QAAQ;YAYR,aAAa;CAO5B"}
@@ -0,0 +1,382 @@
1
+ // Zero-dependency OpenAI-compatible HTTP adapter built on globalThis.fetch and
2
+ // AbortSignal. fetch, clock, request-id, and cost class are injected so tests run
3
+ // with no network I/O and no real time. The raw provider body is never echoed into
4
+ // an error; only a redacted, status-level summary is surfaced.
5
+ import { AuthenticationError, CancelledError, ContextOverflowError, ERROR_CODES, GatewayEgressError, ModelRefusalError, ProviderError, RateLimitError, TimeoutError, TransportError, } from "@oscharko-dev/keiko-security/errors/gateway";
6
+ import { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME } from "./config.js";
7
+ import { gatewayFetch, OutboundHttpEgressError, readJsonCapped, readSseStream, } from "./http.js";
8
+ import { normalizeChatResponse, textFromContent } from "./normalize.js";
9
+ import { redact } from "@oscharko-dev/keiko-security";
10
+ const PROVIDER_EMPTY_ASSISTANT_STATUS = 200;
11
+ const GATEWAY_EGRESS_CODES = {
12
+ PROXY_UNREACHABLE: ERROR_CODES.PROXY_UNREACHABLE,
13
+ PROXY_AUTH_REQUIRED: ERROR_CODES.PROXY_AUTH_REQUIRED,
14
+ PROXY_EGRESS_FAILED: ERROR_CODES.PROXY_EGRESS_FAILED,
15
+ PROXY_BLOCKED_BY_POLICY: ERROR_CODES.PROXY_BLOCKED_BY_POLICY,
16
+ TLS_CA_FAILURE: ERROR_CODES.TLS_CA_FAILURE,
17
+ };
18
+ const GATEWAY_EGRESS_MESSAGES = {
19
+ PROXY_UNREACHABLE: "configured proxy is unreachable",
20
+ PROXY_AUTH_REQUIRED: "configured proxy requires authentication",
21
+ PROXY_EGRESS_FAILED: "configured proxy failed outbound egress",
22
+ PROXY_BLOCKED_BY_POLICY: "configured proxy blocked outbound egress",
23
+ TLS_CA_FAILURE: "TLS certificate verification failed for outbound egress",
24
+ };
25
+ function buildMessageContent(content, parts) {
26
+ if (parts === undefined)
27
+ return content;
28
+ return parts.map((part) => part.type === "text"
29
+ ? { type: "text", text: part.text }
30
+ : { type: "image_url", image_url: { url: part.image_url.url } });
31
+ }
32
+ function buildMessage(message) {
33
+ const toolCalls = message.toolCalls?.map((call) => ({
34
+ id: call.id,
35
+ type: "function",
36
+ function: { name: call.name, arguments: JSON.stringify(call.arguments) },
37
+ }));
38
+ return {
39
+ role: message.role,
40
+ content: message.role === "assistant" && toolCalls !== undefined && toolCalls.length > 0
41
+ ? null
42
+ : buildMessageContent(message.content, message.contentParts),
43
+ ...(message.role === "tool" && message.toolCallId !== undefined
44
+ ? { tool_call_id: message.toolCallId }
45
+ : {}),
46
+ ...(toolCalls !== undefined && toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
47
+ };
48
+ }
49
+ function buildBody(request) {
50
+ const messages = request.messages.map(buildMessage);
51
+ const base = { model: request.modelId, messages };
52
+ const tools = request.tools === undefined
53
+ ? undefined
54
+ : request.tools.map((t) => ({
55
+ type: "function",
56
+ function: { name: t.name, description: t.description, parameters: t.parameters },
57
+ }));
58
+ const responseFormat = request.responseFormat?.type === "json_schema"
59
+ ? { type: "json_schema", json_schema: { schema: request.responseFormat.schema } }
60
+ : undefined;
61
+ return {
62
+ ...base,
63
+ ...(tools ? { tools } : {}),
64
+ ...(responseFormat ? { response_format: responseFormat } : {}),
65
+ ...(request.seed !== undefined ? { seed: request.seed } : {}),
66
+ };
67
+ }
68
+ // Streaming body: identical to buildBody plus the OpenAI/Azure streaming flags.
69
+ // `include_usage` requests a final usage-only chunk so token accounting survives.
70
+ function buildStreamBody(request) {
71
+ return {
72
+ ...buildBody(request),
73
+ stream: true,
74
+ stream_options: { include_usage: true },
75
+ };
76
+ }
77
+ const FINISH_REASONS = new Set([
78
+ "stop",
79
+ "tool_calls",
80
+ "length",
81
+ "content_filter",
82
+ "error",
83
+ "cancelled",
84
+ ]);
85
+ function firstStreamChoice(chunk) {
86
+ if (!isRecord(chunk) || !Array.isArray(chunk.choices)) {
87
+ return undefined;
88
+ }
89
+ const choices = chunk.choices;
90
+ const choice = choices[0];
91
+ return isRecord(choice) ? choice : undefined;
92
+ }
93
+ // Extracts the assistant content delta from a streaming chunk, when present.
94
+ function deltaFromChunk(chunk) {
95
+ const choice = firstStreamChoice(chunk);
96
+ const delta = choice !== undefined && isRecord(choice.delta) ? choice.delta : undefined;
97
+ if (delta === undefined || !("content" in delta)) {
98
+ return undefined;
99
+ }
100
+ const content = textFromContent(delta.content);
101
+ return content.length > 0 ? content : undefined;
102
+ }
103
+ function finishReasonFromChunk(chunk) {
104
+ const choice = firstStreamChoice(chunk);
105
+ const raw = choice?.finish_reason;
106
+ return typeof raw === "string" && FINISH_REASONS.has(raw)
107
+ ? raw
108
+ : undefined;
109
+ }
110
+ function nonNegativeCount(value) {
111
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
112
+ }
113
+ // Extracts prompt/completion token counts from the include_usage final chunk.
114
+ function usageFromChunk(chunk) {
115
+ if (!isRecord(chunk) || !isRecord(chunk.usage)) {
116
+ return undefined;
117
+ }
118
+ return {
119
+ prompt: nonNegativeCount(chunk.usage.prompt_tokens),
120
+ completion: nonNegativeCount(chunk.usage.completion_tokens),
121
+ };
122
+ }
123
+ function retryAfterMs(response) {
124
+ const header = response.headers.get("retry-after");
125
+ if (header === null) {
126
+ return null;
127
+ }
128
+ const seconds = Number(header);
129
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
130
+ }
131
+ function isRecord(value) {
132
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ }
134
+ function redactUnknown(value, secrets) {
135
+ if (typeof value === "string") {
136
+ return redact(value, secrets);
137
+ }
138
+ if (Array.isArray(value)) {
139
+ return value.map((item) => redactUnknown(item, secrets));
140
+ }
141
+ if (isRecord(value)) {
142
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item, secrets)]));
143
+ }
144
+ return value;
145
+ }
146
+ function redactRecord(value, secrets) {
147
+ return value === null ? null : redactUnknown(value, secrets);
148
+ }
149
+ function redactToolCall(call, secrets) {
150
+ return {
151
+ ...call,
152
+ name: redact(call.name, secrets),
153
+ arguments: redactUnknown(call.arguments, secrets),
154
+ };
155
+ }
156
+ function redactResponse(response, secrets) {
157
+ return {
158
+ ...response,
159
+ content: redact(response.content, secrets),
160
+ toolCalls: response.toolCalls.map((call) => redactToolCall(call, secrets)),
161
+ structuredOutput: redactRecord(response.structuredOutput, secrets),
162
+ };
163
+ }
164
+ function assertUsableAssistantResponse(response, modelId, secrets) {
165
+ if (response.content.trim().length > 0 || response.toolCalls.length > 0) {
166
+ return;
167
+ }
168
+ throw new ProviderError(`provider returned an empty assistant response for '${modelId}'`, PROVIDER_EMPTY_ASSISTANT_STATUS, secrets);
169
+ }
170
+ function errorSignal(payload) {
171
+ const error = isRecord(payload) && isRecord(payload.error) ? payload.error : payload;
172
+ if (!isRecord(error)) {
173
+ return "";
174
+ }
175
+ return [error.code, error.type, error.message]
176
+ .filter((value) => typeof value === "string")
177
+ .join(" ")
178
+ .toLowerCase();
179
+ }
180
+ function isContextOverflow(response, payload) {
181
+ if (response.status !== 400 && response.status !== 413 && response.status !== 422) {
182
+ return false;
183
+ }
184
+ return /context[_ -]?length[_ -]?exceeded|context window|context.*exceed|maximum context|too many tokens|prompt too long|context overflow/.test(errorSignal(payload));
185
+ }
186
+ function isModelRefusal(payload) {
187
+ return /content[_ -]?filter|refus|safety|policy/.test(errorSignal(payload));
188
+ }
189
+ function mapHttpError(response, modelId, secrets, payload) {
190
+ if (isContextOverflow(response, payload)) {
191
+ throw new ContextOverflowError(`provider reported context overflow for '${modelId}'`, secrets);
192
+ }
193
+ if (isModelRefusal(payload)) {
194
+ throw new ModelRefusalError(`provider refused the request for '${modelId}'`, secrets);
195
+ }
196
+ if (response.status === 401 || response.status === 403) {
197
+ throw new AuthenticationError(`provider rejected credentials for '${modelId}'`, secrets);
198
+ }
199
+ if (response.status === 429) {
200
+ throw new RateLimitError(`provider rate limited '${modelId}'`, retryAfterMs(response), secrets);
201
+ }
202
+ throw new ProviderError(`provider returned HTTP ${String(response.status)} for '${modelId}'`, response.status, secrets);
203
+ }
204
+ function apiKeyHeaders(config) {
205
+ const headerName = config.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME;
206
+ return { [headerName]: apiKeyHeaderValue(headerName, config.apiKey) };
207
+ }
208
+ function mapOutboundEgressError(error, secrets) {
209
+ if (!(error instanceof OutboundHttpEgressError))
210
+ return undefined;
211
+ return new GatewayEgressError(GATEWAY_EGRESS_CODES[error.code], GATEWAY_EGRESS_MESSAGES[error.code], secrets);
212
+ }
213
+ export class OpenAiAdapter {
214
+ deps;
215
+ now;
216
+ constructor(deps) {
217
+ this.deps = deps;
218
+ this.now = deps.now ?? Date.now;
219
+ }
220
+ call = async (request, config) => {
221
+ const secrets = [config.apiKey, config.baseUrl];
222
+ if (request.cancellationSignal?.aborted === true) {
223
+ throw new CancelledError(`request for '${config.modelId}' cancelled before dispatch`, secrets);
224
+ }
225
+ const start = this.now();
226
+ const response = await this.dispatch(request, config, secrets);
227
+ if (!response.ok) {
228
+ const errorPayload = await this.readErrorBody(response);
229
+ mapHttpError(response, config.modelId, secrets, errorPayload);
230
+ }
231
+ const payload = await this.readBody(response, config, secrets);
232
+ const normalized = normalizeChatResponse(payload, config.modelId, {
233
+ requestId: this.deps.requestId,
234
+ latencyMs: this.now() - start,
235
+ costClass: this.deps.costClass,
236
+ }, request.responseFormat?.type === "json_schema");
237
+ assertUsableAssistantResponse(normalized, config.modelId, secrets);
238
+ return redactResponse(normalized, secrets);
239
+ };
240
+ // Streaming chat path (Layer 1): yields redacted content-delta tokens as they
241
+ // arrive, then a terminal `done` with the assembled, redacted NormalizedResponse.
242
+ // Tool-call streaming is out of scope — only `choices[0].delta.content` is surfaced.
243
+ callStream = async function* (request, config) {
244
+ const secrets = [config.apiKey, config.baseUrl];
245
+ if (request.cancellationSignal?.aborted === true) {
246
+ throw new CancelledError(`request for '${config.modelId}' cancelled before dispatch`, secrets);
247
+ }
248
+ const start = this.now();
249
+ const response = await this.dispatch(request, config, secrets, true);
250
+ if (!response.ok) {
251
+ const errorPayload = await this.readErrorBody(response);
252
+ mapHttpError(response, config.modelId, secrets, errorPayload);
253
+ }
254
+ const acc = { content: "", finishReason: "stop", prompt: 0, completion: 0 };
255
+ for await (const token of this.streamDeltas(response, config, secrets, acc)) {
256
+ yield { type: "delta", token };
257
+ }
258
+ const assembled = this.assembleResponse(config, start, acc);
259
+ assertUsableAssistantResponse(assembled, config.modelId, secrets);
260
+ yield { type: "done", response: redactResponse(assembled, secrets) };
261
+ };
262
+ // Iterates the SSE stream, yielding redacted content tokens while mutating `acc`.
263
+ // Redaction is applied to the full accumulated buffer on every delta so that a
264
+ // configured secret straddling two SSE chunk boundaries is still caught. We track
265
+ // how many characters of the REDACTED output we have already emitted and yield only
266
+ // the new suffix each time. When a cross-delta match shrinks the redacted string
267
+ // relative to what was emitted, the cursor overshoots and the tail (containing the
268
+ // secret or its replacement) is deferred to the terminal `done` chunk, which always
269
+ // redacts the full `acc.content` independently via redactResponse().
270
+ async *streamDeltas(response, config, secrets, acc) {
271
+ let emittedRedactedLength = 0;
272
+ try {
273
+ for await (const chunk of readSseStream(response)) {
274
+ const content = deltaFromChunk(chunk);
275
+ if (content !== undefined) {
276
+ acc.content += content;
277
+ const redacted = redact(acc.content, secrets);
278
+ if (redacted.length > emittedRedactedLength) {
279
+ yield redacted.slice(emittedRedactedLength);
280
+ emittedRedactedLength = redacted.length;
281
+ }
282
+ }
283
+ const finish = finishReasonFromChunk(chunk);
284
+ if (finish !== undefined)
285
+ acc.finishReason = finish;
286
+ const usage = usageFromChunk(chunk);
287
+ if (usage !== undefined) {
288
+ acc.prompt = usage.prompt;
289
+ acc.completion = usage.completion;
290
+ }
291
+ }
292
+ }
293
+ catch (error) {
294
+ throw this.mapStreamError(error, config, secrets);
295
+ }
296
+ }
297
+ assembleResponse(config, start, acc) {
298
+ const usage = {
299
+ requestId: this.deps.requestId,
300
+ promptTokens: acc.prompt,
301
+ completionTokens: acc.completion,
302
+ latencyMs: this.now() - start,
303
+ costClass: this.deps.costClass,
304
+ };
305
+ return {
306
+ modelId: config.modelId,
307
+ content: acc.content,
308
+ finishReason: acc.finishReason,
309
+ toolCalls: [],
310
+ structuredOutput: null,
311
+ usage,
312
+ };
313
+ }
314
+ // A mid-stream read failure surfaces as a TransportError; an already-typed
315
+ // cancellation/timeout (e.g. raised by the underlying reader) passes through.
316
+ mapStreamError(error, config, secrets) {
317
+ if (error instanceof CancelledError || error instanceof TimeoutError) {
318
+ return error;
319
+ }
320
+ const egressError = mapOutboundEgressError(error, secrets);
321
+ if (egressError !== undefined) {
322
+ return egressError;
323
+ }
324
+ return new TransportError(`stream read failed for '${config.modelId}'`, secrets);
325
+ }
326
+ async dispatch(request, config, secrets, stream = false) {
327
+ const timeoutSignal = AbortSignal.timeout(config.timeoutMs);
328
+ const cancel = request.cancellationSignal;
329
+ const signal = cancel ? AbortSignal.any([timeoutSignal, cancel]) : timeoutSignal;
330
+ const url = `${config.baseUrl}/chat/completions`;
331
+ const body = JSON.stringify(stream ? buildStreamBody(request) : buildBody(request));
332
+ const headers = {
333
+ "content-type": "application/json",
334
+ ...apiKeyHeaders(config),
335
+ };
336
+ try {
337
+ return await gatewayFetch(url, {
338
+ method: "POST",
339
+ headers,
340
+ body,
341
+ signal,
342
+ fetchImpl: this.deps.fetchImpl,
343
+ ...(config.egress !== undefined ? { egress: config.egress } : {}),
344
+ });
345
+ }
346
+ catch (error) {
347
+ throw this.mapDispatchError(error, config, cancel, timeoutSignal, secrets);
348
+ }
349
+ }
350
+ mapDispatchError(error, config, cancel, timeout, secrets) {
351
+ if (cancel?.aborted === true) {
352
+ return new CancelledError(`request for '${config.modelId}' cancelled`, secrets);
353
+ }
354
+ const egressError = mapOutboundEgressError(error, secrets);
355
+ if (egressError !== undefined) {
356
+ return egressError;
357
+ }
358
+ if (timeout.aborted) {
359
+ return new TimeoutError(`request for '${config.modelId}' timed out`, secrets);
360
+ }
361
+ if (error instanceof DOMException && error.name === "TimeoutError") {
362
+ return new TimeoutError(`request for '${config.modelId}' timed out`, secrets);
363
+ }
364
+ return new TransportError(`transport failure contacting '${config.modelId}'`, secrets);
365
+ }
366
+ async readBody(response, config, secrets) {
367
+ try {
368
+ return await readJsonCapped(response);
369
+ }
370
+ catch {
371
+ throw new TransportError(`provider sent an unreadable body for '${config.modelId}'`, secrets);
372
+ }
373
+ }
374
+ async readErrorBody(response) {
375
+ try {
376
+ return await readJsonCapped(response);
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ }