Skip to content

Commit ce0fc7a

Browse files
roomotedaniel-lxs
authored andcommitted
fix: add API key validation for non-ASCII characters in OpenAI-compatible providers
- Created shared validation utility to check for non-ASCII characters in API keys - Added validation to all OpenAI-compatible providers to prevent ByteString conversion errors - Provides clear error messages when invalid characters are detected - Fixes issue #7483 where non-ASCII characters in API keys caused cryptic errors
1 parent 966ed76 commit ce0fc7a

File tree

12 files changed

+202
-7
lines changed

12 files changed

+202
-7
lines changed

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format"
1010
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1111
import { DEFAULT_HEADERS } from "./constants"
1212
import { BaseProvider } from "./base-provider"
13+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1314

1415
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
1516
providerName: string
@@ -55,6 +56,9 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
5556
throw new Error("API key is required")
5657
}
5758

59+
// Validate API key for ByteString compatibility
60+
validateApiKeyForByteString(this.options.apiKey, this.providerName)
61+
5862
this.client = new OpenAI({
5963
baseURL,
6064
apiKey: this.options.apiKey,

src/api/providers/huggingface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ".
88
import { DEFAULT_HEADERS } from "./constants"
99
import { BaseProvider } from "./base-provider"
1010
import { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface"
11+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1112

1213
export class HuggingFaceHandler extends BaseProvider implements SingleCompletionHandler {
1314
private client: OpenAI
@@ -22,6 +23,9 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
2223
throw new Error("Hugging Face API key is required")
2324
}
2425

26+
// Validate API key for ByteString compatibility
27+
validateApiKeyForByteString(this.options.huggingFaceApiKey, "HuggingFace")
28+
2529
this.client = new OpenAI({
2630
baseURL: "https://router.huggingface.co/v1",
2731
apiKey: this.options.huggingFaceApiKey,

src/api/providers/lm-studio.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { BaseProvider } from "./base-provider"
1515
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1616
import { getModels, getModelsFromCache } from "./fetchers/modelCache"
1717
import { getApiRequestTimeout } from "./utils/timeout-config"
18+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1819

1920
export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler {
2021
protected options: ApiHandlerOptions
@@ -24,9 +25,13 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
2425
super()
2526
this.options = options
2627

28+
// LM Studio uses "noop" as a placeholder API key, but we should still validate if a real key is provided
29+
const apiKey = "noop"
30+
validateApiKeyForByteString(apiKey, "LM Studio")
31+
2732
this.client = new OpenAI({
2833
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
29-
apiKey: "noop",
34+
apiKey: apiKey,
3035
timeout: getApiRequestTimeout(),
3136
})
3237
}

src/api/providers/ollama.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ApiStream } from "../transform/stream"
1414
import { BaseProvider } from "./base-provider"
1515
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1616
import { getApiRequestTimeout } from "./utils/timeout-config"
17+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1718

1819
type CompletionUsage = OpenAI.Chat.Completions.ChatCompletionChunk["usage"]
1920

@@ -29,6 +30,9 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
2930
// Otherwise use "ollama" as a placeholder for local instances
3031
const apiKey = this.options.ollamaApiKey || "ollama"
3132

33+
// Validate API key for ByteString compatibility
34+
validateApiKeyForByteString(apiKey, "Ollama")
35+
3236
const headers: Record<string, string> = {}
3337
if (this.options.ollamaApiKey) {
3438
headers["Authorization"] = `Bearer ${this.options.ollamaApiKey}`

src/api/providers/openai-native.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getModelParams } from "../transform/model-params"
2222

2323
import { BaseProvider } from "./base-provider"
2424
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
25+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
2526

2627
export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
2728

@@ -59,6 +60,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
5960
this.options.enableGpt5ReasoningSummary = true
6061
}
6162
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
63+
64+
// Validate API key for ByteString compatibility
65+
validateApiKeyForByteString(apiKey, "OpenAI Native")
66+
6267
this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey })
6368
}
6469

src/api/providers/openai.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DEFAULT_HEADERS } from "./constants"
2424
import { BaseProvider } from "./base-provider"
2525
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
2626
import { getApiRequestTimeout } from "./utils/timeout-config"
27+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
2728

2829
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
2930
// `OpenAINativeHandler` can subclass from this, since it's obviously
@@ -42,6 +43,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
4243
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
4344
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
4445

46+
// Validate API key for ByteString compatibility
47+
validateApiKeyForByteString(apiKey, "OpenAI")
48+
4549
const headers = {
4650
...DEFAULT_HEADERS,
4751
...(this.options.openAiHeaders || {}),

src/api/providers/openrouter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache"
2525
import { DEFAULT_HEADERS } from "./constants"
2626
import { BaseProvider } from "./base-provider"
2727
import type { SingleCompletionHandler } from "../index"
28+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
2829

2930
// Image generation types
3031
interface ImageGenerationResponse {
@@ -93,6 +94,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
9394
const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1"
9495
const apiKey = this.options.openRouterApiKey ?? "not-provided"
9596

97+
// Validate API key for ByteString compatibility
98+
validateApiKeyForByteString(apiKey, "OpenRouter")
99+
96100
this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS })
97101
}
98102

src/api/providers/requesty.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getModels } from "./fetchers/modelCache"
1616
import { BaseProvider } from "./base-provider"
1717
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1818
import { toRequestyServiceUrl } from "../../shared/utils/requesty"
19+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1920

2021
// Requesty usage includes an extra field for Anthropic use cases.
2122
// Safely cast the prompt token details section to the appropriate structure.
@@ -49,9 +50,14 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
4950
this.options = options
5051
this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl)
5152

53+
const apiKey = this.options.requestyApiKey ?? "not-provided"
54+
55+
// Validate API key for ByteString compatibility
56+
validateApiKeyForByteString(apiKey, "Requesty")
57+
5258
this.client = new OpenAI({
5359
baseURL: this.baseURL,
54-
apiKey: this.options.requestyApiKey ?? "not-provided",
60+
apiKey: apiKey,
5561
defaultHeaders: DEFAULT_HEADERS,
5662
})
5763
}

src/api/providers/router-provider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BaseProvider } from "./base-provider"
88
import { getModels } from "./fetchers/modelCache"
99

1010
import { DEFAULT_HEADERS } from "./constants"
11+
import { validateApiKeyForByteString } from "./utils/api-key-validation"
1112

1213
type RouterProviderOptions = {
1314
name: RouterName
@@ -45,6 +46,9 @@ export abstract class RouterProvider extends BaseProvider {
4546
this.defaultModelId = defaultModelId
4647
this.defaultModelInfo = defaultModelInfo
4748

49+
// Validate API key for ByteString compatibility
50+
validateApiKeyForByteString(apiKey, name)
51+
4852
this.client = new OpenAI({
4953
baseURL,
5054
apiKey,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect } from "vitest"
2+
import { validateApiKeyForByteString, validateApiKeyForAscii } from "../api-key-validation"
3+
4+
describe("API Key Validation", () => {
5+
describe("validateApiKeyForByteString", () => {
6+
it("should accept valid ASCII characters", () => {
7+
expect(() => validateApiKeyForByteString("abc123XYZ", "TestProvider")).not.toThrow()
8+
expect(() => validateApiKeyForByteString("test-api-key_123", "TestProvider")).not.toThrow()
9+
expect(() => validateApiKeyForByteString("!@#$%^&*()", "TestProvider")).not.toThrow()
10+
})
11+
12+
it("should accept extended ASCII characters (128-255)", () => {
13+
// Extended ASCII characters like ñ (241), ü (252)
14+
expect(() => validateApiKeyForByteString("test\xF1\xFC", "TestProvider")).not.toThrow()
15+
expect(() => validateApiKeyForByteString("key\xFF", "TestProvider")).not.toThrow()
16+
})
17+
18+
it("should reject characters above 255", () => {
19+
// Chinese character 中 (20013)
20+
expect(() => validateApiKeyForByteString("test中key", "TestProvider")).toThrow(
21+
"Invalid TestProvider API key: contains non-ASCII character at position 5",
22+
)
23+
24+
// Emoji 😀 (128512)
25+
expect(() => validateApiKeyForByteString("key😀", "TestProvider")).toThrow(
26+
"Invalid TestProvider API key: contains non-ASCII character at position 4",
27+
)
28+
29+
// Korean character 한 (54620)
30+
expect(() => validateApiKeyForByteString("한글key", "TestProvider")).toThrow(
31+
"Invalid TestProvider API key: contains non-ASCII character at position 1",
32+
)
33+
})
34+
35+
it("should handle undefined and empty keys", () => {
36+
expect(() => validateApiKeyForByteString(undefined, "TestProvider")).not.toThrow()
37+
expect(() => validateApiKeyForByteString("", "TestProvider")).not.toThrow()
38+
})
39+
40+
it("should provide clear error messages", () => {
41+
expect(() => validateApiKeyForByteString("abc中def", "DeepSeek")).toThrow(
42+
"Invalid DeepSeek API key: contains non-ASCII character at position 4. " +
43+
"API keys must contain only ASCII characters (character codes 0-255). " +
44+
"Please check your API key configuration.",
45+
)
46+
})
47+
})
48+
49+
describe("validateApiKeyForAscii", () => {
50+
it("should accept standard ASCII characters (0-127)", () => {
51+
expect(() => validateApiKeyForAscii("abc123XYZ", "TestProvider")).not.toThrow()
52+
expect(() => validateApiKeyForAscii("test-api-key_123", "TestProvider")).not.toThrow()
53+
expect(() => validateApiKeyForAscii("!@#$%^&*()", "TestProvider")).not.toThrow()
54+
})
55+
56+
it("should reject extended ASCII characters (128-255)", () => {
57+
// Extended ASCII character ñ (241)
58+
expect(() => validateApiKeyForAscii("test\xF1key", "TestProvider")).toThrow(
59+
"Invalid TestProvider API key: contains non-ASCII character at position 5",
60+
)
61+
62+
// Extended ASCII character ü (252)
63+
expect(() => validateApiKeyForAscii("key\xFC", "TestProvider")).toThrow(
64+
"Invalid TestProvider API key: contains non-ASCII character at position 4",
65+
)
66+
})
67+
68+
it("should reject Unicode characters", () => {
69+
// Chinese character 中 (20013)
70+
expect(() => validateApiKeyForAscii("test中key", "TestProvider")).toThrow(
71+
"Invalid TestProvider API key: contains non-ASCII character at position 5",
72+
)
73+
74+
// Emoji 😀 (128512)
75+
expect(() => validateApiKeyForAscii("key😀", "TestProvider")).toThrow(
76+
"Invalid TestProvider API key: contains non-ASCII character at position 4",
77+
)
78+
})
79+
80+
it("should handle undefined and empty keys", () => {
81+
expect(() => validateApiKeyForAscii(undefined, "TestProvider")).not.toThrow()
82+
expect(() => validateApiKeyForAscii("", "TestProvider")).not.toThrow()
83+
})
84+
85+
it("should provide clear error messages", () => {
86+
expect(() => validateApiKeyForAscii("abc\xF1def", "OpenAI")).toThrow(
87+
"Invalid OpenAI API key: contains non-ASCII character at position 4. " +
88+
"API keys must contain only standard ASCII characters (character codes 0-127). " +
89+
"Please check your API key configuration.",
90+
)
91+
})
92+
})
93+
})

0 commit comments

Comments
 (0)