Skip to content

Commit 535df86

Browse files
committed
lint(providers): enforce canonical identifiers
1 parent 6c9e836 commit 535df86

6 files changed

Lines changed: 205 additions & 5 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Linter } from "eslint"
2+
import { describe, expect, it } from "vitest"
3+
4+
import { noRawProviderIdentifiers } from "../eslint-rules/no-raw-provider-identifiers.mjs"
5+
6+
const linter = new Linter({ configType: "eslintrc" })
7+
8+
linter.defineRule("zoo/no-raw-provider-identifiers", noRawProviderIdentifiers)
9+
10+
function lint(code) {
11+
return linter.verify(code, {
12+
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
13+
rules: { "zoo/no-raw-provider-identifiers": "error" },
14+
})
15+
}
16+
17+
describe("no-raw-provider-identifiers", () => {
18+
it("rejects a canonical provider literal in an apiProvider property", () => {
19+
const messages = lint('const config = { apiProvider: "poe" }')
20+
21+
expect(messages).toHaveLength(1)
22+
expect(messages[0]).toMatchObject({
23+
ruleId: "zoo/no-raw-provider-identifiers",
24+
message: 'Use providerIdentifiers.poe instead of the raw provider identifier "poe".',
25+
})
26+
})
27+
28+
it("allows a non-canonical literal and a canonical registry member", () => {
29+
expect(lint('const config = { apiProvider: "external-provider" }')).toHaveLength(0)
30+
expect(lint("const config = { apiProvider: providerIdentifiers.poe }")).toHaveLength(0)
31+
})
32+
33+
it("matches provider-like property names and static template literals", () => {
34+
const messages = lint('const config = { provider: "poe", imageProvider: `openrouter` }')
35+
36+
expect(messages).toHaveLength(2)
37+
})
38+
39+
it("rejects canonical literals in provider-like assignments and comparisons", () => {
40+
const messages = lint(`
41+
config["apiProvider"] = "poe"
42+
if (imageProvider === "openrouter") {}
43+
if ("openai-native" !== config.fallbackProvider) {}
44+
`)
45+
46+
expect(messages.map(({ message }) => message)).toEqual([
47+
'Use providerIdentifiers.poe instead of the raw provider identifier "poe".',
48+
'Use providerIdentifiers.openrouter instead of the raw provider identifier "openrouter".',
49+
'Use providerIdentifiers.openaiNative instead of the raw provider identifier "openai-native".',
50+
])
51+
})
52+
53+
it("rejects canonical literals in provider-like switch cases", () => {
54+
const messages = lint(`
55+
switch (config.apiProvider) {
56+
case "poe": break
57+
case providerIdentifiers.openrouter: break
58+
}
59+
`)
60+
61+
expect(messages).toHaveLength(1)
62+
expect(messages[0].message).toContain("providerIdentifiers.poe")
63+
})
64+
65+
it("does not report canonical values outside provider-like contexts", () => {
66+
const messages = lint(`
67+
const label = "poe"
68+
const config = { protocol: "anthropic", format: "openai" }
69+
config[dynamicKey] = "poe"
70+
if (apiProtocol === "anthropic") {}
71+
if (provider > "poe") {}
72+
switch (format) { case "openai": break }
73+
`)
74+
75+
expect(messages).toHaveLength(0)
76+
})
77+
})

‎src/core/tools/GenerateImageTool.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IMAGE_GENERATION_MODEL_IDS,
77
IMAGE_GENERATION_MODELS,
88
getImageGenerationProvider,
9+
providerIdentifiers,
910
} from "@roo-code/types"
1011
import { Task } from "../task/Task"
1112
import { formatResponse } from "../prompts/responses"
@@ -155,7 +156,7 @@ export class GenerateImageTool extends BaseTool<"generate_image"> {
155156
// Validate API key for OpenRouter
156157
const openRouterApiKey = state?.openRouterImageApiKey
157158

158-
if (imageProvider === "openrouter" && !openRouterApiKey) {
159+
if (imageProvider === providerIdentifiers.openrouter && !openRouterApiKey) {
159160
const errorMessage = t("tools:generateImage.openRouterApiKeyRequired")
160161
await task.say("error", errorMessage)
161162
pushToolResult(formatResponse.toolError(errorMessage))

‎src/core/webview/ClineProvider.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,7 +2013,7 @@ export class ClineProvider
20132013

20142014
const newConfiguration: ProviderSettings = {
20152015
...apiConfiguration,
2016-
apiProvider: "openrouter",
2016+
apiProvider: providerIdentifiers.openrouter,
20172017
openRouterApiKey: apiKey,
20182018
openRouterModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
20192019
}
@@ -2059,7 +2059,7 @@ export class ClineProvider
20592059
if (zooProfiles.length === 0) {
20602060
// No existing zoo-gateway profile — create the canonical default.
20612061
const newConfiguration: ProviderSettings = {
2062-
apiProvider: "zoo-gateway",
2062+
apiProvider: providerIdentifiers.zooGateway,
20632063
zooSessionToken: token,
20642064
zooGatewayModelId: apiConfiguration.zooGatewayModelId,
20652065
zooGatewayBaseUrl: derivedGatewayBaseUrl,
@@ -2107,7 +2107,7 @@ export class ClineProvider
21072107

21082108
const newConfiguration: ProviderSettings = {
21092109
...apiConfiguration,
2110-
apiProvider: "requesty",
2110+
apiProvider: providerIdentifiers.requesty,
21112111
requestyApiKey: code,
21122112
requestyModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
21132113
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
2+
3+
const providerMembersByValue = new Map(Object.entries(providerIdentifiers).map(([member, value]) => [value, member]))
4+
5+
function getStaticName(node) {
6+
if (node?.type === "Identifier") {
7+
return node.name
8+
}
9+
10+
if (node?.type === "MemberExpression") {
11+
if (!node.computed && node.property.type === "Identifier") {
12+
return node.property.name
13+
}
14+
15+
if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") {
16+
return node.property.value
17+
}
18+
}
19+
20+
if (node?.type === "Literal" && typeof node.value === "string") {
21+
return node.value
22+
}
23+
24+
return undefined
25+
}
26+
27+
function isProviderLike(node) {
28+
return getStaticName(node)?.toLowerCase().includes("provider") ?? false
29+
}
30+
31+
function getRawProvider(node) {
32+
if (node?.type === "Literal" && typeof node.value === "string") {
33+
const member = providerMembersByValue.get(node.value)
34+
return member ? { member, value: node.value } : undefined
35+
}
36+
37+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
38+
const value = node.quasis[0]?.value.cooked
39+
const member = value ? providerMembersByValue.get(value) : undefined
40+
return member ? { member, value } : undefined
41+
}
42+
43+
return undefined
44+
}
45+
46+
export const noRawProviderIdentifiers = {
47+
meta: {
48+
type: "problem",
49+
docs: { description: "Require canonical provider identifiers in provider-like contexts" },
50+
schema: [],
51+
messages: {
52+
useCanonical:
53+
'Use providerIdentifiers.{{member}} instead of the raw provider identifier "{{value}}".',
54+
},
55+
},
56+
create(context) {
57+
function reportIfRawProvider(node) {
58+
const provider = getRawProvider(node)
59+
if (provider) {
60+
context.report({ node, messageId: "useCanonical", data: provider })
61+
}
62+
}
63+
64+
return {
65+
Property(node) {
66+
if (isProviderLike(node.key)) {
67+
reportIfRawProvider(node.value)
68+
}
69+
},
70+
AssignmentExpression(node) {
71+
if (isProviderLike(node.left)) {
72+
reportIfRawProvider(node.right)
73+
}
74+
},
75+
BinaryExpression(node) {
76+
if (!["===", "!==", "==", "!="].includes(node.operator)) {
77+
return
78+
}
79+
80+
if (isProviderLike(node.left)) {
81+
reportIfRawProvider(node.right)
82+
}
83+
if (isProviderLike(node.right)) {
84+
reportIfRawProvider(node.left)
85+
}
86+
},
87+
SwitchStatement(node) {
88+
if (isProviderLike(node.discriminant)) {
89+
for (const switchCase of node.cases) {
90+
reportIfRawProvider(switchCase.test)
91+
}
92+
}
93+
},
94+
}
95+
},
96+
}

‎src/eslint.config.mjs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { config } from "@roo-code/config-eslint/base"
22

3+
import { noRawProviderIdentifiers } from "./eslint-rules/no-raw-provider-identifiers.mjs"
4+
35
/** @type {import("eslint").Linter.Config} */
46
export default [
57
...config,
@@ -31,6 +33,30 @@ export default [
3133
"no-undef": "off",
3234
},
3335
},
36+
{
37+
files: ["**/*.ts", "**/*.tsx"],
38+
ignores: [
39+
"**/__tests__/**",
40+
"**/__mocks__/**",
41+
"**/*.spec.ts",
42+
"**/*.test.ts",
43+
"**/fixtures/**",
44+
// Embedding providers are a separate domain whose identifiers only partially
45+
// overlap with the canonical API provider registry.
46+
"services/code-index/**",
47+
"shared/embeddingModels.ts",
48+
],
49+
plugins: {
50+
zoo: {
51+
rules: {
52+
"no-raw-provider-identifiers": noRawProviderIdentifiers,
53+
},
54+
},
55+
},
56+
rules: {
57+
"zoo/no-raw-provider-identifiers": "error",
58+
},
59+
},
3460
{
3561
// Ratchet: enforce no-floating-promises directory by directory. Each
3662
// directory is added here once its floating promises are resolved.

‎src/vitest.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default defineConfig({
1919
coverage: {
2020
provider: "v8",
2121
reporter: ["text", "lcov"],
22-
include: ["src/**/*.ts", "src/**/*.tsx"],
22+
include: ["src/**/*.ts", "src/**/*.tsx", "eslint-rules/**/*.mjs"],
2323
exclude: [
2424
"**/*.test.ts",
2525
"**/*.test.tsx",

0 commit comments

Comments
 (0)