opencode-google-auth 0.0.6 → 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 +12 -13
- package/src/main.ts +155 -142
- package/src/services/config.ts +78 -148
- 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"
|
|
@@ -34,26 +34,25 @@
|
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const generationConfig = request.generationConfig
|
|
26
|
+
yield* Effect.log("Trying endpoint", endpoint)
|
|
27
|
+
yield* Effect.log("Input", input)
|
|
28
|
+
yield* Effect.log("Init", init)
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{ ...loggedBody, request: { generationConfig } },
|
|
47
|
-
)
|
|
30
|
+
const [finalInput, finalInit] = config.requestTransform?.(input, init) ?? [
|
|
31
|
+
input,
|
|
32
|
+
init,
|
|
33
|
+
]
|
|
48
34
|
|
|
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,95 +99,7 @@ export const geminiCli: Plugin = async (context) => {
|
|
|
124
99
|
)
|
|
125
100
|
|
|
126
101
|
return {
|
|
127
|
-
apiKey:
|
|
128
|
-
fetch: (async (input, init) => {
|
|
129
|
-
const response = await runtime.runPromise(customFetch(input, init))
|
|
130
|
-
return response
|
|
131
|
-
}) as typeof fetch,
|
|
132
|
-
} satisfies GoogleGenerativeAIProviderSettings
|
|
133
|
-
},
|
|
134
|
-
methods: [
|
|
135
|
-
{
|
|
136
|
-
type: "oauth",
|
|
137
|
-
label: "OAuth with Google",
|
|
138
|
-
authorize: async () => {
|
|
139
|
-
const result = await runtime.runPromise(
|
|
140
|
-
Effect.gen(function* () {
|
|
141
|
-
const oauth = yield* OAuth
|
|
142
|
-
return yield* oauth.authenticate
|
|
143
|
-
}),
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
url: "",
|
|
148
|
-
method: "auto",
|
|
149
|
-
instructions: "You are now authenticated!",
|
|
150
|
-
callback: async () => {
|
|
151
|
-
const accessToken = result.access_token
|
|
152
|
-
const refreshToken = result.refresh_token
|
|
153
|
-
const expiryDate = result.expiry_date
|
|
154
|
-
|
|
155
|
-
if (!accessToken || !refreshToken || !expiryDate) {
|
|
156
|
-
return { type: "failed" }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
type: "success",
|
|
161
|
-
provider: config.id as string,
|
|
162
|
-
access: accessToken,
|
|
163
|
-
refresh: refreshToken,
|
|
164
|
-
expires: expiryDate,
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
],
|
|
171
|
-
},
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export const antigravity: Plugin = async (context) => {
|
|
176
|
-
const runtime = makeRuntime({
|
|
177
|
-
openCodeCtx: context,
|
|
178
|
-
providerConfig: antigravityConfig(),
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
const config = await runtime.runPromise(
|
|
182
|
-
Effect.gen(function* () {
|
|
183
|
-
const providerConfig = yield* ProviderConfig
|
|
184
|
-
const modelsDev = yield* fetchModelsDev
|
|
185
|
-
|
|
186
|
-
return providerConfig.getConfig(modelsDev)
|
|
187
|
-
}),
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
config: async (cfg) => {
|
|
192
|
-
cfg.provider ??= {}
|
|
193
|
-
cfg.provider[config.id as string] = config
|
|
194
|
-
},
|
|
195
|
-
auth: {
|
|
196
|
-
provider: config.id as string,
|
|
197
|
-
loader: async (getAuth) => {
|
|
198
|
-
const auth = await getAuth()
|
|
199
|
-
if (auth.type !== "oauth") return {}
|
|
200
|
-
|
|
201
|
-
const credentials: Credentials = {
|
|
202
|
-
access_token: auth.access,
|
|
203
|
-
refresh_token: auth.refresh,
|
|
204
|
-
expiry_date: auth.expires,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
await runtime.runPromise(
|
|
208
|
-
Effect.gen(function* () {
|
|
209
|
-
const session = yield* Session
|
|
210
|
-
yield* session.setCredentials(credentials)
|
|
211
|
-
}),
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
apiKey: "",
|
|
102
|
+
apiKey: auth.access,
|
|
216
103
|
fetch: (async (input, init) => {
|
|
217
104
|
const response = await runtime.runPromise(customFetch(input, init))
|
|
218
105
|
return response
|
|
@@ -257,27 +144,153 @@ export const antigravity: Plugin = async (context) => {
|
|
|
257
144
|
},
|
|
258
145
|
],
|
|
259
146
|
},
|
|
260
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
261
|
-
// THIS IS REQUIRED OTHERWISE YOU'LL GET 429 OR 403 FOR SOME GODDAMN REASON
|
|
262
|
-
output.system.unshift(antigravitySpoof)
|
|
263
|
-
},
|
|
264
147
|
"chat.params": async (input, output) => {
|
|
265
|
-
await runtime.runPromise(
|
|
266
|
-
Effect.
|
|
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
|
+
}),
|
|
267
158
|
)
|
|
268
159
|
|
|
269
|
-
if (input.model.providerID
|
|
160
|
+
if (config.id === input.model.providerID) {
|
|
270
161
|
output.options = {
|
|
271
162
|
...output.options,
|
|
272
|
-
|
|
273
|
-
sessionId: input.sessionID,
|
|
274
|
-
},
|
|
163
|
+
projectId: options.projectId,
|
|
275
164
|
} satisfies GoogleGenerativeAIProviderOptions
|
|
276
165
|
}
|
|
277
166
|
|
|
278
167
|
await runtime.runPromise(
|
|
279
|
-
Effect.log("chat.params
|
|
168
|
+
Effect.log("chat.params", config.id, input.model, output),
|
|
280
169
|
)
|
|
281
170
|
},
|
|
282
171
|
}
|
|
283
172
|
}
|
|
173
|
+
|
|
174
|
+
// export const antigravity: Plugin = async (context) => {
|
|
175
|
+
// const runtime = makeRuntime({
|
|
176
|
+
// openCodeCtx: context,
|
|
177
|
+
// providerConfig: antigravityConfig(),
|
|
178
|
+
// })
|
|
179
|
+
|
|
180
|
+
// const config = await runtime.runPromise(
|
|
181
|
+
// Effect.gen(function* () {
|
|
182
|
+
// const providerConfig = yield* ProviderConfig
|
|
183
|
+
// const modelsDev = yield* fetchModelsDev
|
|
184
|
+
|
|
185
|
+
// const config = providerConfig.getConfig(modelsDev)
|
|
186
|
+
// yield* Effect.log("Config initialized", config.id)
|
|
187
|
+
|
|
188
|
+
// return config
|
|
189
|
+
// }),
|
|
190
|
+
// )
|
|
191
|
+
|
|
192
|
+
// return {
|
|
193
|
+
// config: async (cfg) => {
|
|
194
|
+
// cfg.provider ??= {}
|
|
195
|
+
// cfg.provider[config.id as string] = config
|
|
196
|
+
// },
|
|
197
|
+
// auth: {
|
|
198
|
+
// provider: config.id as string,
|
|
199
|
+
// loader: async (getAuth) => {
|
|
200
|
+
// const auth = await getAuth()
|
|
201
|
+
// if (auth.type !== "oauth") return {}
|
|
202
|
+
|
|
203
|
+
// const credentials: Credentials = {
|
|
204
|
+
// access_token: auth.access,
|
|
205
|
+
// refresh_token: auth.refresh,
|
|
206
|
+
// expiry_date: auth.expires,
|
|
207
|
+
// }
|
|
208
|
+
|
|
209
|
+
// await runtime.runPromise(
|
|
210
|
+
// Effect.gen(function* () {
|
|
211
|
+
// const session = yield* Session
|
|
212
|
+
// yield* session.setCredentials(credentials)
|
|
213
|
+
// }),
|
|
214
|
+
// )
|
|
215
|
+
|
|
216
|
+
// return {
|
|
217
|
+
// apiKey: auth.access,
|
|
218
|
+
// fetch: (async (input, init) => {
|
|
219
|
+
// const response = await runtime.runPromise(customFetch(input, init))
|
|
220
|
+
// return response
|
|
221
|
+
// }) as typeof fetch,
|
|
222
|
+
// } satisfies GoogleGenerativeAIProviderSettings
|
|
223
|
+
// },
|
|
224
|
+
// methods: [
|
|
225
|
+
// {
|
|
226
|
+
// type: "oauth",
|
|
227
|
+
// label: "OAuth with Google",
|
|
228
|
+
// authorize: async () => {
|
|
229
|
+
// const result = await runtime.runPromise(
|
|
230
|
+
// Effect.gen(function* () {
|
|
231
|
+
// const oauth = yield* OAuth
|
|
232
|
+
// return yield* oauth.authenticate
|
|
233
|
+
// }),
|
|
234
|
+
// )
|
|
235
|
+
|
|
236
|
+
// return {
|
|
237
|
+
// url: "",
|
|
238
|
+
// method: "auto",
|
|
239
|
+
// instructions: "You are now authenticated!",
|
|
240
|
+
// callback: async () => {
|
|
241
|
+
// const accessToken = result.access_token
|
|
242
|
+
// const refreshToken = result.refresh_token
|
|
243
|
+
// const expiryDate = result.expiry_date
|
|
244
|
+
|
|
245
|
+
// if (!accessToken || !refreshToken || !expiryDate) {
|
|
246
|
+
// return { type: "failed" }
|
|
247
|
+
// }
|
|
248
|
+
|
|
249
|
+
// return {
|
|
250
|
+
// type: "success",
|
|
251
|
+
// provider: config.id as string,
|
|
252
|
+
// access: accessToken,
|
|
253
|
+
// refresh: refreshToken,
|
|
254
|
+
// expires: expiryDate,
|
|
255
|
+
// }
|
|
256
|
+
// },
|
|
257
|
+
// }
|
|
258
|
+
// },
|
|
259
|
+
// },
|
|
260
|
+
// ],
|
|
261
|
+
// },
|
|
262
|
+
// "experimental.chat.system.transform": async (input, output) => {
|
|
263
|
+
// output.system.unshift(antigravitySpoof)
|
|
264
|
+
// },
|
|
265
|
+
// "chat.params": async (input, output) => {
|
|
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
|
+
// }),
|
|
278
|
+
// )
|
|
279
|
+
|
|
280
|
+
// if (config.id === input.model.providerID) {
|
|
281
|
+
// output.options = {
|
|
282
|
+
// ...output.options,
|
|
283
|
+
// userAgent: "antigravity",
|
|
284
|
+
// requestType: "agent",
|
|
285
|
+
// requestId: `agent-${requestId}`,
|
|
286
|
+
// sessionId: input.sessionID,
|
|
287
|
+
// projectId: options.projectId,
|
|
288
|
+
// } satisfies GoogleGenerativeAIProviderOptions
|
|
289
|
+
// }
|
|
290
|
+
|
|
291
|
+
// await runtime.runPromise(
|
|
292
|
+
// Effect.log("chat.params", config.id, input.model, output),
|
|
293
|
+
// )
|
|
294
|
+
// },
|
|
295
|
+
// }
|
|
296
|
+
// }
|
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<
|
|
@@ -181,149 +178,82 @@ export const antigravityConfig = (): ProviderConfigShape => ({
|
|
|
181
178
|
},
|
|
182
179
|
} satisfies GoogleGenerativeAIProviderOptions,
|
|
183
180
|
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
181
|
+
"claude-sonnet-4-5": {
|
|
182
|
+
...claudeSonnet,
|
|
183
|
+
id: "claude-sonnet-4-5",
|
|
184
|
+
reasoning: false,
|
|
185
|
+
options: {
|
|
186
|
+
thinkingConfig: {
|
|
187
|
+
includeThoughts: false,
|
|
188
|
+
},
|
|
189
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
190
|
+
},
|
|
191
|
+
"claude-sonnet-4-5-thinking": {
|
|
192
|
+
...claudeSonnet,
|
|
193
|
+
id: "claude-sonnet-4-5-thinking",
|
|
194
|
+
name: "Claude Sonnet 4.5 (Thinking)",
|
|
195
|
+
options: {
|
|
196
|
+
thinkingConfig: {
|
|
197
|
+
includeThoughts: true,
|
|
198
|
+
thinkingBudget: 31999,
|
|
199
|
+
},
|
|
200
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
201
|
+
variants: {
|
|
202
|
+
high: {
|
|
203
|
+
thinkingConfig: {
|
|
204
|
+
includeThoughts: true,
|
|
205
|
+
thinkingBudget: 16000,
|
|
206
|
+
},
|
|
207
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
208
|
+
max: {
|
|
209
|
+
thinkingConfig: {
|
|
210
|
+
includeThoughts: true,
|
|
211
|
+
thinkingBudget: 31999,
|
|
212
|
+
},
|
|
213
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
"claude-opus-4-5-thinking": {
|
|
217
|
+
...claudeOpus,
|
|
218
|
+
id: "claude-opus-4-5-thinking",
|
|
219
|
+
name: "Claude Opus 4.5 (Thinking)",
|
|
220
|
+
options: {
|
|
221
|
+
thinkingConfig: {
|
|
222
|
+
includeThoughts: true,
|
|
223
|
+
thinkingBudget: 31999,
|
|
224
|
+
},
|
|
225
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
226
|
+
variants: {
|
|
227
|
+
high: {
|
|
228
|
+
thinkingConfig: {
|
|
229
|
+
includeThoughts: true,
|
|
230
|
+
thinkingBudget: 16000,
|
|
231
|
+
},
|
|
232
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
233
|
+
max: {
|
|
234
|
+
thinkingConfig: {
|
|
235
|
+
includeThoughts: true,
|
|
236
|
+
thinkingBudget: 31999,
|
|
237
|
+
},
|
|
238
|
+
} satisfies GoogleGenerativeAIProviderOptions,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
244
241
|
}
|
|
245
242
|
|
|
246
243
|
return {
|
|
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
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Handle thinkingConfig for Claude models
|
|
279
|
-
const isClaude = body.model.toLowerCase().includes("claude")
|
|
280
|
-
const isThinking = body.model.toLowerCase().includes("thinking")
|
|
281
|
-
|
|
282
|
-
if (isClaude && body.request && typeof body.request === "object") {
|
|
283
|
-
const request = body.request as Record<string, unknown>
|
|
284
|
-
const tools = request.tools as
|
|
285
|
-
| Array<{ functionDeclarations?: Array<{ parameters?: unknown }> }>
|
|
286
|
-
| undefined
|
|
287
|
-
if (tools && Array.isArray(tools)) {
|
|
288
|
-
for (const tool of tools) {
|
|
289
|
-
if (
|
|
290
|
-
tool.functionDeclarations
|
|
291
|
-
&& Array.isArray(tool.functionDeclarations)
|
|
292
|
-
) {
|
|
293
|
-
for (const func of tool.functionDeclarations) {
|
|
294
|
-
if (!func.parameters) {
|
|
295
|
-
func.parameters = { type: "object", properties: {} }
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
innerRequest.toolConfig = {
|
|
303
|
-
functionCallingConfig: {
|
|
304
|
-
mode: "VALIDATED",
|
|
305
|
-
},
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (isThinking) {
|
|
309
|
-
headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (sessionId) {
|
|
314
|
-
innerRequest.sessionId = sessionId
|
|
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)
|
|
315
255
|
}
|
|
316
256
|
|
|
317
|
-
return {
|
|
318
|
-
|
|
319
|
-
url,
|
|
320
|
-
body: {
|
|
321
|
-
requestType: "agent",
|
|
322
|
-
userAgent: "antigravity",
|
|
323
|
-
requestId: `agent-${crypto.randomUUID()}`,
|
|
324
|
-
...body,
|
|
325
|
-
},
|
|
326
|
-
}
|
|
327
|
-
}),
|
|
328
|
-
skipRequestTransform: true,
|
|
257
|
+
return [_input, { ...init, headers }]
|
|
258
|
+
},
|
|
329
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
|
-
}
|