opencode-google-auth 0.0.5 → 0.0.7
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.
- package/package.json +13 -14
- package/src/main.ts +67 -54
- package/src/services/config.ts +18 -69
- package/src/types.ts +3 -0
- package/src/transform/request.test.ts +0 -176
- package/src/transform/request.ts +0 -101
- package/src/transform/response.test.ts +0 -75
- package/src/transform/response.ts +0 -27
- package/src/transform/stream.test.ts +0 -108
- package/src/transform/stream.ts +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-google-auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "_description_",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode-google-auth"
|
|
@@ -28,32 +28,31 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"deploy": "bun build ./src/main.ts --target bun --outfile .opencode/plugin/opencode-google-auth.js",
|
|
30
30
|
"format": "prettier --write .",
|
|
31
|
-
"lint": "oxlint",
|
|
31
|
+
"lint": "oxlint --type-aware",
|
|
32
32
|
"release": "bumpp && npm publish",
|
|
33
33
|
"typecheck": "tsgo"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@effect/experimental": "^0.58.0",
|
|
37
|
-
"@effect/platform": "^0.94.
|
|
38
|
-
"@effect/platform-bun": "^0.87.
|
|
39
|
-
"@opencode-ai/plugin": "^1.1.
|
|
37
|
+
"@effect/platform": "^0.94.2",
|
|
38
|
+
"@effect/platform-bun": "^0.87.1",
|
|
39
|
+
"@opencode-ai/plugin": "^1.1.36",
|
|
40
40
|
"arkregex": "^0.0.5",
|
|
41
|
-
"effect": "^3.19.
|
|
41
|
+
"effect": "^3.19.15",
|
|
42
42
|
"google-auth-library": "^10.5.0",
|
|
43
43
|
"open": "^11.0.0",
|
|
44
44
|
"xdg-basedir": "^5.1.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@
|
|
48
|
-
"@
|
|
49
|
-
"@types/bun": "^1.3.5",
|
|
47
|
+
"@effect/language-service": "^0.72.0",
|
|
48
|
+
"@types/bun": "^1.3.6",
|
|
50
49
|
"@types/yargs": "^17.0.35",
|
|
51
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
52
|
-
"ai": "^6.0.33",
|
|
50
|
+
"@typescript/native-preview": "^7.0.0-dev.20260126.1",
|
|
53
51
|
"bumpp": "^10.4.0",
|
|
54
|
-
"
|
|
55
|
-
"oxlint
|
|
56
|
-
"
|
|
52
|
+
"cloudassist-ai-provider": "^0.0.2",
|
|
53
|
+
"oxlint": "^1.42.0",
|
|
54
|
+
"oxlint-tsgolint": "^0.11.2",
|
|
55
|
+
"prettier": "^3.8.1",
|
|
57
56
|
"typescript": "^5.9.3",
|
|
58
57
|
"yargs": "^18.0.0"
|
|
59
58
|
},
|
package/src/main.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
+
import { HttpClient } from "@effect/platform"
|
|
2
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
1
3
|
import type {
|
|
2
4
|
GoogleGenerativeAIProviderOptions,
|
|
3
5
|
GoogleGenerativeAIProviderSettings,
|
|
4
|
-
} from "
|
|
5
|
-
import { HttpClient } from "@effect/platform"
|
|
6
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
6
|
+
} from "cloudassist-ai-provider"
|
|
7
7
|
import { Effect } from "effect"
|
|
8
8
|
import { makeRuntime } from "./lib/runtime"
|
|
9
|
-
import {
|
|
10
|
-
antigravityConfig,
|
|
11
|
-
geminiCliConfig,
|
|
12
|
-
ProviderConfig,
|
|
13
|
-
} from "./services/config"
|
|
9
|
+
import { geminiCliConfig, ProviderConfig } from "./services/config"
|
|
14
10
|
import { OAuth } from "./services/oauth"
|
|
15
11
|
import { Session } from "./services/session"
|
|
16
|
-
import {
|
|
17
|
-
import { transformNonStreamingResponse } from "./transform/response"
|
|
18
|
-
import { transformStreamingResponse } from "./transform/stream"
|
|
19
|
-
import type { Credentials, ModelsDev } from "./types"
|
|
20
|
-
import antigravitySpoof from "./antigravity-spoof.txt"
|
|
12
|
+
import type { Credentials, FetchInit, FetchInput, ModelsDev } from "./types"
|
|
21
13
|
|
|
22
14
|
const fetchModelsDev = Effect.gen(function* () {
|
|
23
15
|
const client = yield* HttpClient.HttpClient
|
|
@@ -25,34 +17,25 @@ const fetchModelsDev = Effect.gen(function* () {
|
|
|
25
17
|
return (yield* response.json) as ModelsDev
|
|
26
18
|
})
|
|
27
19
|
|
|
28
|
-
const customFetch = Effect.fn(function* (
|
|
29
|
-
input: Parameters<typeof fetch>[0],
|
|
30
|
-
init: Parameters<typeof fetch>[1],
|
|
31
|
-
) {
|
|
20
|
+
const customFetch = Effect.fn(function* (input: FetchInput, init: FetchInit) {
|
|
32
21
|
const config = yield* ProviderConfig
|
|
33
22
|
|
|
34
23
|
let lastResponse: Response | null = null
|
|
35
24
|
|
|
36
25
|
for (const endpoint of config.ENDPOINTS) {
|
|
37
|
-
|
|
26
|
+
yield* Effect.log("Trying endpoint", endpoint)
|
|
27
|
+
yield* Effect.log("Input", input)
|
|
28
|
+
yield* Effect.log("Init", init)
|
|
38
29
|
|
|
39
|
-
const
|
|
40
|
-
|
|
30
|
+
const [finalInput, finalInit] = config.requestTransform?.(input, init) ?? [
|
|
31
|
+
input,
|
|
32
|
+
init,
|
|
33
|
+
]
|
|
41
34
|
|
|
42
|
-
yield* Effect.
|
|
43
|
-
"Transformed request (Omitting request except generationConfig) :",
|
|
44
|
-
result.streaming,
|
|
45
|
-
result.input,
|
|
46
|
-
{ ...loggedBody, request: { generationConfig } },
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
const response = yield* Effect.promise(() =>
|
|
50
|
-
fetch(result.input, result.init),
|
|
51
|
-
)
|
|
35
|
+
const response = yield* Effect.promise(() => fetch(finalInput, finalInit))
|
|
52
36
|
|
|
53
37
|
// On 429 or 403, try next endpoint
|
|
54
38
|
if (response.status === 429 || response.status === 403) {
|
|
55
|
-
yield* Effect.log(`${response.status} on ${endpoint}, trying next...`)
|
|
56
39
|
lastResponse = response
|
|
57
40
|
continue
|
|
58
41
|
}
|
|
@@ -61,7 +44,7 @@ const customFetch = Effect.fn(function* (
|
|
|
61
44
|
const cloned = response.clone()
|
|
62
45
|
const clonedJson = yield* Effect.promise(() => cloned.json())
|
|
63
46
|
|
|
64
|
-
yield* Effect.
|
|
47
|
+
yield* Effect.logWarning(
|
|
65
48
|
"Received response:",
|
|
66
49
|
cloned.status,
|
|
67
50
|
clonedJson,
|
|
@@ -69,18 +52,10 @@ const customFetch = Effect.fn(function* (
|
|
|
69
52
|
)
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
|
|
73
|
-
yield* Effect.log("Skipping response transformation")
|
|
74
|
-
return response
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return result.streaming ?
|
|
78
|
-
transformStreamingResponse(response)
|
|
79
|
-
: yield* Effect.promise(() => transformNonStreamingResponse(response))
|
|
55
|
+
return response
|
|
80
56
|
}
|
|
81
57
|
|
|
82
|
-
|
|
83
|
-
yield* Effect.logWarning("All endpoints rate limited (429)")
|
|
58
|
+
yield* Effect.logError("All endpoints are rate limited (429)")
|
|
84
59
|
return lastResponse as Response
|
|
85
60
|
}, Effect.tapDefect(Effect.logError))
|
|
86
61
|
|
|
@@ -124,7 +99,7 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
124
99
|
)
|
|
125
100
|
|
|
126
101
|
return {
|
|
127
|
-
apiKey:
|
|
102
|
+
apiKey: auth.access,
|
|
128
103
|
fetch: (async (input, init) => {
|
|
129
104
|
const response = await runtime.runPromise(customFetch(input, init))
|
|
130
105
|
return response
|
|
@@ -169,6 +144,30 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
169
144
|
},
|
|
170
145
|
],
|
|
171
146
|
},
|
|
147
|
+
"chat.params": async (input, output) => {
|
|
148
|
+
const options = await runtime.runPromise(
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
const session = yield* Session
|
|
151
|
+
const project = yield* session.ensureProject
|
|
152
|
+
const projectId = project.cloudaicompanionProject
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
projectId,
|
|
156
|
+
}
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (config.id === input.model.providerID) {
|
|
161
|
+
output.options = {
|
|
162
|
+
...output.options,
|
|
163
|
+
projectId: options.projectId,
|
|
164
|
+
} satisfies GoogleGenerativeAIProviderOptions
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await runtime.runPromise(
|
|
168
|
+
Effect.log("chat.params", config.id, input.model, output),
|
|
169
|
+
)
|
|
170
|
+
},
|
|
172
171
|
}
|
|
173
172
|
}
|
|
174
173
|
|
|
@@ -183,7 +182,10 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
183
182
|
// const providerConfig = yield* ProviderConfig
|
|
184
183
|
// const modelsDev = yield* fetchModelsDev
|
|
185
184
|
|
|
186
|
-
//
|
|
185
|
+
// const config = providerConfig.getConfig(modelsDev)
|
|
186
|
+
// yield* Effect.log("Config initialized", config.id)
|
|
187
|
+
|
|
188
|
+
// return config
|
|
187
189
|
// }),
|
|
188
190
|
// )
|
|
189
191
|
|
|
@@ -212,7 +214,7 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
212
214
|
// )
|
|
213
215
|
|
|
214
216
|
// return {
|
|
215
|
-
// apiKey:
|
|
217
|
+
// apiKey: auth.access,
|
|
216
218
|
// fetch: (async (input, init) => {
|
|
217
219
|
// const response = await runtime.runPromise(customFetch(input, init))
|
|
218
220
|
// return response
|
|
@@ -257,26 +259,37 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
257
259
|
// },
|
|
258
260
|
// ],
|
|
259
261
|
// },
|
|
260
|
-
// "experimental.chat.system.transform": async (
|
|
261
|
-
// // THIS IS REQUIRED OTHERWISE YOU'LL GET 429 OR 403 FOR SOME GODDAMN REASON
|
|
262
|
+
// "experimental.chat.system.transform": async (input, output) => {
|
|
262
263
|
// output.system.unshift(antigravitySpoof)
|
|
263
264
|
// },
|
|
264
265
|
// "chat.params": async (input, output) => {
|
|
265
|
-
//
|
|
266
|
-
|
|
266
|
+
// const requestId = crypto.randomUUID()
|
|
267
|
+
|
|
268
|
+
// const options = await runtime.runPromise(
|
|
269
|
+
// Effect.gen(function* () {
|
|
270
|
+
// const session = yield* Session
|
|
271
|
+
// const project = yield* session.ensureProject
|
|
272
|
+
// const projectId = project.cloudaicompanionProject
|
|
273
|
+
|
|
274
|
+
// return {
|
|
275
|
+
// projectId,
|
|
276
|
+
// }
|
|
277
|
+
// }),
|
|
267
278
|
// )
|
|
268
279
|
|
|
269
|
-
// if (input.model.providerID
|
|
280
|
+
// if (config.id === input.model.providerID) {
|
|
270
281
|
// output.options = {
|
|
271
282
|
// ...output.options,
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// }
|
|
283
|
+
// userAgent: "antigravity",
|
|
284
|
+
// requestType: "agent",
|
|
285
|
+
// requestId: `agent-${requestId}`,
|
|
286
|
+
// sessionId: input.sessionID,
|
|
287
|
+
// projectId: options.projectId,
|
|
275
288
|
// } satisfies GoogleGenerativeAIProviderOptions
|
|
276
289
|
// }
|
|
277
290
|
|
|
278
291
|
// await runtime.runPromise(
|
|
279
|
-
// Effect.log("chat.params
|
|
292
|
+
// Effect.log("chat.params", config.id, input.model, output),
|
|
280
293
|
// )
|
|
281
294
|
// },
|
|
282
295
|
// }
|
package/src/services/config.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"
|
|
2
|
-
import { Context,
|
|
2
|
+
import { Context, pipe } from "effect"
|
|
3
3
|
import type {
|
|
4
|
+
FetchInit,
|
|
5
|
+
FetchInput,
|
|
4
6
|
ModelsDev,
|
|
5
7
|
OpenCodeModel,
|
|
6
8
|
OpenCodeProvider,
|
|
@@ -28,12 +30,10 @@ export interface ProviderConfigShape {
|
|
|
28
30
|
readonly CLIENT_ID: string
|
|
29
31
|
readonly CLIENT_SECRET: string
|
|
30
32
|
readonly getConfig: (modelsDev: ModelsDev) => OpenCodeProvider
|
|
31
|
-
readonly
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}>
|
|
36
|
-
readonly skipRequestTransform?: boolean
|
|
33
|
+
readonly requestTransform?: (
|
|
34
|
+
input: FetchInput,
|
|
35
|
+
init: FetchInit,
|
|
36
|
+
) => [FetchInput, FetchInit]
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export class ProviderConfig extends Context.Tag("ProviderConfig")<
|
|
@@ -100,11 +100,10 @@ export const geminiCliConfig = (): ProviderConfigShape => ({
|
|
|
100
100
|
...provider,
|
|
101
101
|
id: geminiCliConfig().SERVICE_NAME,
|
|
102
102
|
name: geminiCliConfig().DISPLAY_NAME,
|
|
103
|
-
|
|
103
|
+
npm: "cloudassist-ai-provider",
|
|
104
104
|
models: filteredModels as Record<string, OpenCodeModel>,
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
|
-
skipRequestTransform: false,
|
|
108
107
|
})
|
|
109
108
|
|
|
110
109
|
export const antigravityConfig = (): ProviderConfigShape => ({
|
|
@@ -133,9 +132,7 @@ export const antigravityConfig = (): ProviderConfigShape => ({
|
|
|
133
132
|
},
|
|
134
133
|
getConfig: (modelsDev) => {
|
|
135
134
|
const googleProvider = modelsDev.google as Provider
|
|
136
|
-
const googleVertextProvider = modelsDev
|
|
137
|
-
"google-vertex-anthropic"
|
|
138
|
-
] as Provider
|
|
135
|
+
const googleVertextProvider = modelsDev.anthropic as Provider
|
|
139
136
|
|
|
140
137
|
const geminiFlash = googleProvider.models[
|
|
141
138
|
"gemini-3-flash-preview"
|
|
@@ -144,10 +141,10 @@ export const antigravityConfig = (): ProviderConfigShape => ({
|
|
|
144
141
|
"gemini-3-pro-preview"
|
|
145
142
|
] as OpenCodeModel
|
|
146
143
|
const claudeSonnet = googleVertextProvider.models[
|
|
147
|
-
"claude-sonnet-4-5
|
|
144
|
+
"claude-sonnet-4-5"
|
|
148
145
|
] as OpenCodeModel
|
|
149
146
|
const claudeOpus = googleVertextProvider.models[
|
|
150
|
-
"claude-opus-4-5
|
|
147
|
+
"claude-opus-4-5"
|
|
151
148
|
] as OpenCodeModel
|
|
152
149
|
|
|
153
150
|
const models: Record<
|
|
@@ -247,64 +244,16 @@ export const antigravityConfig = (): ProviderConfigShape => ({
|
|
|
247
244
|
...googleProvider,
|
|
248
245
|
id: antigravityConfig().SERVICE_NAME,
|
|
249
246
|
name: antigravityConfig().DISPLAY_NAME,
|
|
250
|
-
|
|
251
|
-
npm: "google-antigravity-ai-provider",
|
|
247
|
+
npm: "cloudassist-ai-provider",
|
|
252
248
|
models,
|
|
253
249
|
}
|
|
254
250
|
},
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
const { body, headers, url } = context
|
|
262
|
-
const innerRequest = body.request as Record<string, unknown>
|
|
263
|
-
|
|
264
|
-
let sessionId: string | undefined
|
|
265
|
-
if (
|
|
266
|
-
innerRequest.labels
|
|
267
|
-
&& typeof innerRequest.labels === "object"
|
|
268
|
-
&& "sessionId" in innerRequest.labels
|
|
269
|
-
) {
|
|
270
|
-
const labels = innerRequest.labels as Record<string, unknown>
|
|
271
|
-
sessionId = labels.sessionId as string
|
|
272
|
-
delete labels.sessionId
|
|
273
|
-
if (Object.keys(labels).length === 0) {
|
|
274
|
-
delete innerRequest.labels
|
|
275
|
-
}
|
|
251
|
+
requestTransform: (_input, init) => {
|
|
252
|
+
const headers = new Headers(init?.headers)
|
|
253
|
+
for (const [key, value] of Object.entries(antigravityConfig().HEADERS)) {
|
|
254
|
+
headers.set(key, value)
|
|
276
255
|
}
|
|
277
256
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const isThinking = body.model.toLowerCase().includes("thinking")
|
|
281
|
-
|
|
282
|
-
if (isClaude && body.request && typeof body.request === "object") {
|
|
283
|
-
innerRequest.toolConfig = {
|
|
284
|
-
functionCallingConfig: {
|
|
285
|
-
mode: "VALIDATED",
|
|
286
|
-
},
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (isThinking) {
|
|
290
|
-
headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (sessionId) {
|
|
295
|
-
innerRequest.sessionId = sessionId
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return {
|
|
299
|
-
headers,
|
|
300
|
-
url,
|
|
301
|
-
body: {
|
|
302
|
-
requestType: "agent",
|
|
303
|
-
userAgent: "antigravity",
|
|
304
|
-
requestId: `agent-${crypto.randomUUID()}`,
|
|
305
|
-
...body,
|
|
306
|
-
},
|
|
307
|
-
}
|
|
308
|
-
}),
|
|
309
|
-
skipRequestTransform: true,
|
|
257
|
+
return [_input, { ...init, headers }]
|
|
258
|
+
},
|
|
310
259
|
})
|
package/src/types.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type OpenCodeLogLevel = NonNullable<
|
|
|
17
17
|
|
|
18
18
|
export type BunServeOptions = Partial<Bun.Serve.Options<undefined, never>>
|
|
19
19
|
|
|
20
|
+
export type FetchInput = Parameters<typeof fetch>[0]
|
|
21
|
+
export type FetchInit = Parameters<typeof fetch>[1]
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* Subset of google-auth-library Credentials type.
|
|
22
25
|
*
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { transformRequest } from "./request"
|
|
3
|
-
import { geminiCliConfig, ProviderConfig } from "../services/config"
|
|
4
|
-
import { Session } from "../services/session"
|
|
5
|
-
import { Effect, Layer, pipe } from "effect"
|
|
6
|
-
|
|
7
|
-
describe("transformRequest", () => {
|
|
8
|
-
const baseParams = {
|
|
9
|
-
accessToken: "test-token",
|
|
10
|
-
projectId: "test-project-123",
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const config = geminiCliConfig()
|
|
14
|
-
|
|
15
|
-
const MockSession = Layer.succeed(
|
|
16
|
-
Session,
|
|
17
|
-
Session.of({
|
|
18
|
-
getAccessToken: Effect.succeed(baseParams.accessToken),
|
|
19
|
-
ensureProject: Effect.succeed({
|
|
20
|
-
cloudaicompanionProject: baseParams.projectId,
|
|
21
|
-
currentTier: {
|
|
22
|
-
id: "free",
|
|
23
|
-
name: "Free",
|
|
24
|
-
description: "",
|
|
25
|
-
userDefinedCloudaicompanionProject: false,
|
|
26
|
-
},
|
|
27
|
-
allowedTiers: [],
|
|
28
|
-
gcpManaged: false,
|
|
29
|
-
manageSubscriptionUri: "",
|
|
30
|
-
}),
|
|
31
|
-
setCredentials: () => Effect.void,
|
|
32
|
-
} as unknown as Session),
|
|
33
|
-
).pipe(Layer.provideMerge(Layer.succeed(ProviderConfig, config)))
|
|
34
|
-
|
|
35
|
-
const runTransform = (
|
|
36
|
-
input: Parameters<typeof fetch>[0],
|
|
37
|
-
init: Parameters<typeof fetch>[1],
|
|
38
|
-
endpoint = "https://example.com",
|
|
39
|
-
) =>
|
|
40
|
-
pipe(
|
|
41
|
-
transformRequest(input, init, endpoint),
|
|
42
|
-
Effect.provide(MockSession),
|
|
43
|
-
Effect.runPromise,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
describe("URL transformation", () => {
|
|
47
|
-
it("transforms /v1beta/models/{model}:{action} to /v1internal:{action}", async () => {
|
|
48
|
-
const result = await runTransform(
|
|
49
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
50
|
-
undefined,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
expect(result.input).toContain("/v1internal:generateContent")
|
|
54
|
-
expect(result.streaming).toBe(false)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it("detects streaming requests", async () => {
|
|
58
|
-
const result = await runTransform(
|
|
59
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
60
|
-
undefined,
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
expect(result.input).toContain("/v1internal:streamGenerateContent")
|
|
64
|
-
expect(result.input).toContain("alt=sse")
|
|
65
|
-
expect(result.streaming).toBe(true)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it("passes through non-matching URLs", async () => {
|
|
69
|
-
const url = "https://example.com/some/other/path"
|
|
70
|
-
const result = await runTransform(url, undefined)
|
|
71
|
-
|
|
72
|
-
expect(result.input).toBe(url)
|
|
73
|
-
expect(result.streaming).toBe(false)
|
|
74
|
-
})
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
describe("header transformation", () => {
|
|
78
|
-
it("sets Authorization header", async () => {
|
|
79
|
-
const result = await runTransform(
|
|
80
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
81
|
-
{},
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
const headers = result.init.headers as Headers
|
|
85
|
-
expect(headers.get("Authorization")).toBe("Bearer test-token")
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it("removes x-api-key header", async () => {
|
|
89
|
-
const result = await runTransform(
|
|
90
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
91
|
-
{
|
|
92
|
-
headers: {
|
|
93
|
-
"x-api-key": "some-key",
|
|
94
|
-
"x-goog-api-key": "another-key",
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
const headers = result.init.headers as Headers
|
|
100
|
-
expect(headers.get("x-api-key")).toBeNull()
|
|
101
|
-
expect(headers.get("x-goog-api-key")).toBeNull()
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it("sets Accept header for streaming", async () => {
|
|
105
|
-
const result = await runTransform(
|
|
106
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
107
|
-
{},
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
const headers = result.init.headers as Headers
|
|
111
|
-
expect(headers.get("Accept")).toBe("text/event-stream")
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it("applies provider-specific headers", async () => {
|
|
115
|
-
const result = await runTransform(
|
|
116
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
117
|
-
{},
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
const headers = result.init.headers as Headers
|
|
121
|
-
expect(headers.get("User-Agent")).toBe(
|
|
122
|
-
config.HEADERS["User-Agent"] ?? null,
|
|
123
|
-
)
|
|
124
|
-
expect(headers.get("X-Goog-Api-Client")).toBe(
|
|
125
|
-
config.HEADERS["X-Goog-Api-Client"] ?? null,
|
|
126
|
-
)
|
|
127
|
-
})
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
describe("body transformation", () => {
|
|
131
|
-
it("wraps request body with project and model", async () => {
|
|
132
|
-
const originalBody = JSON.stringify({ contents: [{ text: "Hello" }] })
|
|
133
|
-
|
|
134
|
-
const result = await runTransform(
|
|
135
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
136
|
-
{ body: originalBody },
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
const body = JSON.parse(result.init.body as string)
|
|
140
|
-
expect(body.project).toBe("test-project-123")
|
|
141
|
-
expect(body.model).toBe("gemini-2.5-pro")
|
|
142
|
-
expect(body.request).toEqual({ contents: [{ text: "Hello" }] })
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
it("preserves body if not JSON", async () => {
|
|
146
|
-
const originalBody = "not-json"
|
|
147
|
-
|
|
148
|
-
const result = await runTransform(
|
|
149
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
150
|
-
{ body: originalBody },
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
expect(result.init.body).toBe("not-json")
|
|
154
|
-
})
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
describe("input types", () => {
|
|
158
|
-
it("handles URL object", async () => {
|
|
159
|
-
const url = new URL(
|
|
160
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
161
|
-
)
|
|
162
|
-
const result = await runTransform(url, undefined)
|
|
163
|
-
|
|
164
|
-
expect(result.input).toContain("/v1internal:generateContent")
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it("handles Request object", async () => {
|
|
168
|
-
const request = new Request(
|
|
169
|
-
"https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
|
|
170
|
-
)
|
|
171
|
-
const result = await runTransform(request, undefined)
|
|
172
|
-
|
|
173
|
-
expect(result.input).toContain("/v1internal:generateContent")
|
|
174
|
-
})
|
|
175
|
-
})
|
|
176
|
-
})
|
package/src/transform/request.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { regex } from "arkregex"
|
|
2
|
-
import { Effect, pipe } from "effect"
|
|
3
|
-
import {
|
|
4
|
-
CODE_ASSIST_VERSION,
|
|
5
|
-
ProviderConfig,
|
|
6
|
-
type RequestContext,
|
|
7
|
-
} from "../services/config"
|
|
8
|
-
import { Session } from "../services/session"
|
|
9
|
-
|
|
10
|
-
const STREAM_ACTION = "streamGenerateContent"
|
|
11
|
-
const PATH_PATTERN = regex("/models/(?<model>[^:]+):(?<action>\\w+)")
|
|
12
|
-
|
|
13
|
-
export const transformRequest = Effect.fn("transformRequest")(function* (
|
|
14
|
-
input: Parameters<typeof fetch>[0],
|
|
15
|
-
init: Parameters<typeof fetch>[1],
|
|
16
|
-
endpoint: string,
|
|
17
|
-
) {
|
|
18
|
-
const config = yield* ProviderConfig
|
|
19
|
-
const session = yield* Session
|
|
20
|
-
const accessToken = yield* session.getAccessToken
|
|
21
|
-
const project = yield* session.ensureProject
|
|
22
|
-
const projectId = project.cloudaicompanionProject
|
|
23
|
-
|
|
24
|
-
const url = new URL(input instanceof Request ? input.url : input)
|
|
25
|
-
|
|
26
|
-
// Rewrite the URL to use the specified endpoint
|
|
27
|
-
const endpointUrl = new URL(endpoint)
|
|
28
|
-
url.protocol = endpointUrl.protocol
|
|
29
|
-
url.host = endpointUrl.host
|
|
30
|
-
|
|
31
|
-
const match = PATH_PATTERN.exec(url.pathname)
|
|
32
|
-
if (!match) {
|
|
33
|
-
return {
|
|
34
|
-
input: url.toString(),
|
|
35
|
-
init: init ?? {},
|
|
36
|
-
streaming: false,
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const { model, action } = match.groups
|
|
41
|
-
const streaming = action === STREAM_ACTION
|
|
42
|
-
|
|
43
|
-
// Transform URL to internal endpoint
|
|
44
|
-
url.pathname = `/${CODE_ASSIST_VERSION}:${action}`
|
|
45
|
-
if (streaming) {
|
|
46
|
-
url.searchParams.set("alt", "sse")
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Transform headers
|
|
50
|
-
const headers = new Headers(init?.headers)
|
|
51
|
-
headers.delete("x-api-key")
|
|
52
|
-
headers.delete("x-goog-api-key")
|
|
53
|
-
headers.set("Authorization", `Bearer ${accessToken}`)
|
|
54
|
-
|
|
55
|
-
for (const [key, value] of Object.entries(config.HEADERS)) {
|
|
56
|
-
headers.set(key, value)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (streaming) {
|
|
60
|
-
headers.set("Accept", "text/event-stream")
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Wrap and transform request
|
|
64
|
-
const isJson = typeof init?.body === "string"
|
|
65
|
-
const parsedBody = yield* pipe(
|
|
66
|
-
Effect.try(() => (isJson ? JSON.parse(init.body as string) : null)),
|
|
67
|
-
Effect.orElseSucceed(() => null),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
const wrappedBody = {
|
|
71
|
-
model,
|
|
72
|
-
project: projectId,
|
|
73
|
-
request: parsedBody ?? {},
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const {
|
|
77
|
-
body: transformedBody,
|
|
78
|
-
headers: finalHeaders,
|
|
79
|
-
url: finalUrl,
|
|
80
|
-
} =
|
|
81
|
-
config.transformRequest ?
|
|
82
|
-
yield* config.transformRequest({
|
|
83
|
-
body: wrappedBody,
|
|
84
|
-
headers,
|
|
85
|
-
url,
|
|
86
|
-
} satisfies RequestContext)
|
|
87
|
-
: { body: wrappedBody, headers, url }
|
|
88
|
-
|
|
89
|
-
const finalBody =
|
|
90
|
-
isJson && parsedBody ? JSON.stringify(transformedBody) : init?.body
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
input: finalUrl.toString(),
|
|
94
|
-
init: {
|
|
95
|
-
...init,
|
|
96
|
-
headers: finalHeaders,
|
|
97
|
-
body: finalBody,
|
|
98
|
-
},
|
|
99
|
-
streaming,
|
|
100
|
-
}
|
|
101
|
-
})
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { transformNonStreamingResponse } from "./response"
|
|
3
|
-
|
|
4
|
-
describe("transformNonStreamingResponse", () => {
|
|
5
|
-
it("unwraps { response: X } to X", async () => {
|
|
6
|
-
const original = new Response(
|
|
7
|
-
JSON.stringify({ response: { text: "Hello" } }),
|
|
8
|
-
{ headers: { "content-type": "application/json" } },
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
const result = await transformNonStreamingResponse(original)
|
|
12
|
-
const body = await result.json()
|
|
13
|
-
|
|
14
|
-
expect(body).toEqual({ text: "Hello" })
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it("passes through response without wrapper", async () => {
|
|
18
|
-
const original = new Response(JSON.stringify({ text: "Hello" }), {
|
|
19
|
-
headers: { "content-type": "application/json" },
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
const result = await transformNonStreamingResponse(original)
|
|
23
|
-
const body = await result.json()
|
|
24
|
-
|
|
25
|
-
expect(body).toEqual({ text: "Hello" })
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it("passes through non-JSON responses", async () => {
|
|
29
|
-
const original = new Response("plain text", {
|
|
30
|
-
headers: { "content-type": "text/plain" },
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const result = await transformNonStreamingResponse(original)
|
|
34
|
-
const body = await result.text()
|
|
35
|
-
|
|
36
|
-
expect(body).toBe("plain text")
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it("preserves status code", async () => {
|
|
40
|
-
const original = new Response(JSON.stringify({ response: {} }), {
|
|
41
|
-
status: 201,
|
|
42
|
-
statusText: "Created",
|
|
43
|
-
headers: { "content-type": "application/json" },
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
const result = await transformNonStreamingResponse(original)
|
|
47
|
-
|
|
48
|
-
expect(result.status).toBe(201)
|
|
49
|
-
expect(result.statusText).toBe("Created")
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it("preserves headers", async () => {
|
|
53
|
-
const original = new Response(JSON.stringify({ response: {} }), {
|
|
54
|
-
headers: {
|
|
55
|
-
"content-type": "application/json",
|
|
56
|
-
"x-custom-header": "value",
|
|
57
|
-
},
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
const result = await transformNonStreamingResponse(original)
|
|
61
|
-
|
|
62
|
-
expect(result.headers.get("x-custom-header")).toBe("value")
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it("handles malformed JSON gracefully", async () => {
|
|
66
|
-
const original = new Response("not-json{", {
|
|
67
|
-
headers: { "content-type": "application/json" },
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
const result = await transformNonStreamingResponse(original)
|
|
71
|
-
const body = await result.text()
|
|
72
|
-
|
|
73
|
-
expect(body).toBe("not-json{")
|
|
74
|
-
})
|
|
75
|
-
})
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
export const transformNonStreamingResponse = async (
|
|
2
|
-
response: Response,
|
|
3
|
-
): Promise<Response> => {
|
|
4
|
-
const contentType = response.headers.get("content-type")
|
|
5
|
-
|
|
6
|
-
if (!contentType?.includes("application/json")) {
|
|
7
|
-
return response
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const cloned = response.clone()
|
|
12
|
-
const parsed = (await cloned.json()) as { response?: unknown }
|
|
13
|
-
|
|
14
|
-
if (parsed.response !== undefined) {
|
|
15
|
-
const { response: responseData, ...rest } = parsed
|
|
16
|
-
return new Response(JSON.stringify({ ...rest, ...responseData }), {
|
|
17
|
-
status: response.status,
|
|
18
|
-
statusText: response.statusText,
|
|
19
|
-
headers: response.headers,
|
|
20
|
-
})
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
// Return original if parse fails
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return response
|
|
27
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { transformStreamingResponse } from "./stream"
|
|
3
|
-
|
|
4
|
-
describe("transformStreamingResponse", () => {
|
|
5
|
-
const createSSEStream = (events: string[]) => {
|
|
6
|
-
const encoder = new TextEncoder()
|
|
7
|
-
return new ReadableStream({
|
|
8
|
-
start(controller) {
|
|
9
|
-
for (const event of events) {
|
|
10
|
-
controller.enqueue(encoder.encode(event))
|
|
11
|
-
}
|
|
12
|
-
controller.close()
|
|
13
|
-
},
|
|
14
|
-
})
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
it("unwraps response wrapper from SSE events", async () => {
|
|
18
|
-
const stream = createSSEStream([
|
|
19
|
-
'data: {"response":{"text":"Hello"}}\n\n',
|
|
20
|
-
'data: {"response":{"text":" World"}}\n\n',
|
|
21
|
-
])
|
|
22
|
-
|
|
23
|
-
const response = new Response(stream, {
|
|
24
|
-
headers: { "content-type": "text/event-stream" },
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
const result = await transformStreamingResponse(response)
|
|
28
|
-
const text = await result.text()
|
|
29
|
-
|
|
30
|
-
expect(text).toContain('data: {"text":"Hello"}')
|
|
31
|
-
expect(text).toContain('data: {"text":" World"}')
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it("handles responses without wrapper", async () => {
|
|
35
|
-
const stream = createSSEStream(['data: {"text":"Direct"}\n\n'])
|
|
36
|
-
|
|
37
|
-
const response = new Response(stream, {
|
|
38
|
-
headers: { "content-type": "text/event-stream" },
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
const result = await transformStreamingResponse(response)
|
|
42
|
-
const text = await result.text()
|
|
43
|
-
|
|
44
|
-
expect(text).toContain('data: {"text":"Direct"}')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it("returns original response if no body", async () => {
|
|
48
|
-
const response = new Response(null, { status: 204 })
|
|
49
|
-
|
|
50
|
-
const result = await transformStreamingResponse(response)
|
|
51
|
-
|
|
52
|
-
expect(result.status).toBe(204)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it("preserves status and headers", async () => {
|
|
56
|
-
const stream = createSSEStream(['data: {"response":{}}\n\n'])
|
|
57
|
-
|
|
58
|
-
const response = new Response(stream, {
|
|
59
|
-
status: 200,
|
|
60
|
-
statusText: "OK",
|
|
61
|
-
headers: {
|
|
62
|
-
"content-type": "text/event-stream",
|
|
63
|
-
"x-custom": "value",
|
|
64
|
-
},
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const result = await transformStreamingResponse(response)
|
|
68
|
-
|
|
69
|
-
expect(result.status).toBe(200)
|
|
70
|
-
expect(result.headers.get("x-custom")).toBe("value")
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it("handles chunked SSE data", async () => {
|
|
74
|
-
// Simulate data split across chunks
|
|
75
|
-
const stream = createSSEStream([
|
|
76
|
-
'data: {"resp',
|
|
77
|
-
'onse":{"part":"1"}}\n\ndata: {"response":{"part":"2"}}\n\n',
|
|
78
|
-
])
|
|
79
|
-
|
|
80
|
-
const response = new Response(stream, {
|
|
81
|
-
headers: { "content-type": "text/event-stream" },
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
const result = await transformStreamingResponse(response)
|
|
85
|
-
const text = await result.text()
|
|
86
|
-
|
|
87
|
-
expect(text).toContain('data: {"part":"1"}')
|
|
88
|
-
expect(text).toContain('data: {"part":"2"}')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it("filters out non-data lines", async () => {
|
|
92
|
-
const stream = createSSEStream([
|
|
93
|
-
": comment\n",
|
|
94
|
-
"event: message\n",
|
|
95
|
-
'data: {"response":{"valid":"data"}}\n\n',
|
|
96
|
-
])
|
|
97
|
-
|
|
98
|
-
const response = new Response(stream, {
|
|
99
|
-
headers: { "content-type": "text/event-stream" },
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
const result = await transformStreamingResponse(response)
|
|
103
|
-
const text = await result.text()
|
|
104
|
-
|
|
105
|
-
expect(text).toContain('data: {"valid":"data"}')
|
|
106
|
-
expect(text).not.toContain("comment")
|
|
107
|
-
})
|
|
108
|
-
})
|
package/src/transform/stream.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
encoder,
|
|
3
|
-
type Event,
|
|
4
|
-
makeChannel,
|
|
5
|
-
Retry,
|
|
6
|
-
} from "@effect/experimental/Sse"
|
|
7
|
-
import { pipe, Stream } from "effect"
|
|
8
|
-
|
|
9
|
-
const parseAndMerge = (event: Event): string => {
|
|
10
|
-
if (!event.data) {
|
|
11
|
-
return encoder.write(event)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const parsed = JSON.parse(event.data) as {
|
|
16
|
-
response?: Record<string, unknown>
|
|
17
|
-
}
|
|
18
|
-
if (parsed.response) {
|
|
19
|
-
const { response, ...rest } = parsed
|
|
20
|
-
return encoder.write({
|
|
21
|
-
...event,
|
|
22
|
-
data: JSON.stringify({ ...rest, ...response }),
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return encoder.write(event)
|
|
27
|
-
} catch {
|
|
28
|
-
return encoder.write(event)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const parseSSE = (body: ReadableStream<Uint8Array>) =>
|
|
33
|
-
pipe(
|
|
34
|
-
Stream.fromReadableStream(
|
|
35
|
-
() => body,
|
|
36
|
-
(error) => error,
|
|
37
|
-
),
|
|
38
|
-
Stream.decodeText,
|
|
39
|
-
Stream.pipeThroughChannel(makeChannel()),
|
|
40
|
-
Stream.map((event) =>
|
|
41
|
-
Retry.is(event) ? encoder.write(event) : parseAndMerge(event),
|
|
42
|
-
),
|
|
43
|
-
Stream.encodeText,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
export const transformStreamingResponse = (response: Response) => {
|
|
47
|
-
if (!response.body) {
|
|
48
|
-
return response
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const transformed = parseSSE(response.body)
|
|
52
|
-
const readable = Stream.toReadableStream(
|
|
53
|
-
transformed as Stream.Stream<Uint8Array, never, never>,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
return new Response(readable, {
|
|
57
|
-
status: response.status,
|
|
58
|
-
statusText: response.statusText,
|
|
59
|
-
headers: response.headers,
|
|
60
|
-
})
|
|
61
|
-
}
|