Skip to content

Commit 946e099

Browse files
authored
Fix responses (#5546)
1 parent eab025f commit 946e099

File tree

6 files changed

+162
-12
lines changed

6 files changed

+162
-12
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect } from "@jest/globals";
2+
import { registry } from "../../../cost/models/registry";
3+
import { buildRequestBody } from "../../../cost/models/provider-helpers";
4+
import { toChatCompletions } from "@helicone-package/llm-mapper/transform/providers/responses/request/toChatCompletions";
5+
6+
describe("Helicone provider", () => {
7+
describe("GPT 4.1 models with RESPONSES bodyMapping", () => {
8+
const gpt41Models = ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"];
9+
10+
gpt41Models.forEach((modelName) => {
11+
it(`should preserve 'input' parameter for ${modelName} (not convert to 'messages')`, async () => {
12+
const configResult = registry.getModelProviderConfig(
13+
modelName,
14+
"helicone"
15+
);
16+
expect(configResult.data).toBeDefined();
17+
18+
const endpointResult = registry.buildEndpoint(configResult.data!, {});
19+
expect(endpointResult.data).toBeDefined();
20+
21+
// Responses API format uses 'input', not 'messages'
22+
const responsesApiBody = {
23+
model: modelName,
24+
input: "Hello, world!",
25+
max_output_tokens: 100,
26+
};
27+
28+
const result = await buildRequestBody(endpointResult.data!, {
29+
parsedBody: responsesApiBody,
30+
bodyMapping: "RESPONSES",
31+
toAnthropic: (body: any) => body,
32+
toChatCompletions: (body: any) => toChatCompletions(body),
33+
});
34+
35+
expect(result.data).toBeDefined();
36+
const parsedResult = JSON.parse(result.data!);
37+
38+
// Should preserve 'input' and NOT have 'messages'
39+
expect(parsedResult.input).toBe("Hello, world!");
40+
expect(parsedResult.messages).toBeUndefined();
41+
});
42+
});
43+
});
44+
45+
describe("GPT 4o models with RESPONSES bodyMapping", () => {
46+
const gpt4oModels = ["gpt-4o", "gpt-4o-mini"];
47+
48+
gpt4oModels.forEach((modelName) => {
49+
it(`should preserve 'input' parameter for ${modelName} (not convert to 'messages')`, async () => {
50+
const configResult = registry.getModelProviderConfig(
51+
modelName,
52+
"helicone"
53+
);
54+
55+
// Skip if model doesn't have helicone endpoint
56+
if (!configResult.data) {
57+
return;
58+
}
59+
60+
const endpointResult = registry.buildEndpoint(configResult.data!, {});
61+
expect(endpointResult.data).toBeDefined();
62+
63+
const responsesApiBody = {
64+
model: modelName,
65+
input: "Hello, world!",
66+
max_output_tokens: 100,
67+
};
68+
69+
const result = await buildRequestBody(endpointResult.data!, {
70+
parsedBody: responsesApiBody,
71+
bodyMapping: "RESPONSES",
72+
toAnthropic: (body: any) => body,
73+
toChatCompletions: (body: any) => toChatCompletions(body),
74+
});
75+
76+
expect(result.data).toBeDefined();
77+
const parsedResult = JSON.parse(result.data!);
78+
79+
// Should preserve 'input' and NOT have 'messages'
80+
expect(parsedResult.input).toBe("Hello, world!");
81+
expect(parsedResult.messages).toBeUndefined();
82+
});
83+
});
84+
});
85+
86+
describe("Anthropic models with RESPONSES bodyMapping", () => {
87+
it("should convert 'input' to 'messages' for Claude models", async () => {
88+
const configResult = registry.getModelProviderConfig(
89+
"claude-sonnet-4",
90+
"helicone"
91+
);
92+
93+
// Skip if model doesn't have helicone endpoint
94+
if (!configResult.data) {
95+
return;
96+
}
97+
98+
const endpointResult = registry.buildEndpoint(configResult.data!, {});
99+
expect(endpointResult.data).toBeDefined();
100+
101+
const responsesApiBody = {
102+
model: "claude-sonnet-4",
103+
input: "Hello, world!",
104+
max_output_tokens: 100,
105+
};
106+
107+
const result = await buildRequestBody(endpointResult.data!, {
108+
parsedBody: responsesApiBody,
109+
bodyMapping: "RESPONSES",
110+
toAnthropic: (body: any, modelId: string) => ({
111+
...body,
112+
model: modelId,
113+
}),
114+
toChatCompletions: (body: any) => toChatCompletions(body),
115+
});
116+
117+
expect(result.data).toBeDefined();
118+
const parsedResult = JSON.parse(result.data!);
119+
120+
// Should have 'messages' (converted from 'input') for Anthropic
121+
// The body goes through toChatCompletions then toAnthropic
122+
expect(parsedResult.messages).toBeDefined();
123+
expect(parsedResult.input).toBeUndefined();
124+
});
125+
});
126+
});

packages/cost/models/providers/helicone.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BaseProvider } from "./base";
2+
import { nativelySupportsResponsesAPI } from "./utils";
23
import type {
34
AuthContext,
45
AuthResult,
@@ -66,9 +67,9 @@ export class HeliconeProvider extends BaseProvider {
6667
});
6768
}
6869

69-
// Convert responses API format to chat completions format first
70-
// This supports both OpenAI and Anthropic models with the responses API
71-
if (context.bodyMapping === "RESPONSES" && !endpoint.providerModelId.includes("gpt")) {
70+
// Convert responses API format to chat completions format for models that don't natively support it
71+
if (context.bodyMapping === "RESPONSES" &&
72+
!nativelySupportsResponsesAPI("helicone", endpoint.providerModelId)) {
7273
updatedBody = context.toChatCompletions(updatedBody);
7374
}
7475

packages/cost/models/providers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,6 @@ export const ResponsesAPIEnabledProviders: ModelProviderName[] = [
9797

9898
// Re-export base for extending
9999
export { BaseProvider } from "./base";
100+
101+
// Re-export utilities
102+
export { nativelySupportsResponsesAPI } from "./utils";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Check if a provider/model combination natively supports the Responses API format.
3+
* Models that natively support Responses API should NOT have their request/response
4+
* converted to/from Chat Completions format.
5+
*
6+
* Currently supported:
7+
* - OpenAI provider (all models)
8+
* - Helicone provider with GPT models (providerModelId contains "gpt" or "/gt")
9+
* Note: Helicone uses obfuscated model IDs like "pa/gt-4.1-m" for GPT 4.1 models
10+
*
11+
* @param provider - The provider name
12+
* @param providerModelId - The provider-specific model ID
13+
* @returns true if the model natively supports Responses API format
14+
*/
15+
export function nativelySupportsResponsesAPI(
16+
provider: string,
17+
providerModelId: string
18+
): boolean {
19+
return (
20+
provider === "openai" ||
21+
(provider === "helicone" &&
22+
(providerModelId.includes("gpt") || providerModelId.includes("/gt")))
23+
);
24+
}

packages/llm-mapper/transform/providers/normalizeResponse.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getUsageProcessor } from "@helicone-package/cost/usage/getUsageProcessor";
22
import { mapModelUsageToOpenAI } from "@helicone-package/cost/usage/mapModelUsageToOpenAI";
3-
import { ModelProviderName } from "@helicone-package/cost/models/providers";
3+
import { ModelProviderName, nativelySupportsResponsesAPI } from "@helicone-package/cost/models/providers";
44
import {
55
ResponseFormat,
66
BodyMappingType,
@@ -522,8 +522,7 @@ export async function normalizeAIGatewayResponse(params: {
522522

523523
// by this line, normalizedOpenAIText is now in Chat Completions format
524524

525-
const nativelySupportsResponsesAPI = provider === "openai" || (provider === "helicone" && providerModelId.includes("gpt"));
526-
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI) {
525+
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI(provider, providerModelId)) {
527526
return convertOpenAIStreamToResponses(normalizedOpenAIText);
528527
}
529528

@@ -557,8 +556,7 @@ export async function normalizeAIGatewayResponse(params: {
557556
}
558557
}
559558

560-
const nativelySupportsResponsesAPI = provider === "openai" || (provider === "helicone" && providerModelId.includes("gpt"));
561-
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI) {
559+
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI(provider, providerModelId)) {
562560
const responsesBody = toResponses(openAIBody);
563561
return JSON.stringify(responsesBody);
564562
}

worker/src/lib/ai-gateway/SimpleAIGateway.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { DataDogTracer, TraceContext } from "../monitoring/DataDogTracer";
3535
import {
3636
ResponsesAPIEnabledProviders,
3737
ContextEditingEnabledProviders,
38+
nativelySupportsResponsesAPI,
3839
} from "@helicone-package/cost/models/providers";
3940
import { oaiChat2responsesResponse } from "../clients/llmmapper/router/oaiChat2responses/nonStream";
4041
import { oaiChat2responsesStreamResponse } from "../clients/llmmapper/router/oaiChat2responses/stream";
@@ -657,10 +658,7 @@ export class SimpleAIGateway {
657658
}
658659

659660
// Output now is in Chat Completions format
660-
const nativelySupportsResponsesAPI =
661-
provider === "openai" ||
662-
(provider === "helicone" && providerModelId.includes("gpt"));
663-
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI) {
661+
if (bodyMapping === "RESPONSES" && !nativelySupportsResponsesAPI(provider, providerModelId)) {
664662
if (isStream) {
665663
finalMappedResponse =
666664
oaiChat2responsesStreamResponse(finalMappedResponse);

0 commit comments

Comments
 (0)