opencode-google-auth 0.0.1
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/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/main.mjs +716 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +66 -0
- package/src/lib/logger.ts +43 -0
- package/src/lib/runtime.ts +34 -0
- package/src/lib/services/config.test.ts +194 -0
- package/src/lib/services/config.ts +378 -0
- package/src/lib/services/oauth.ts +135 -0
- package/src/lib/services/opencode.ts +7 -0
- package/src/lib/services/session.ts +212 -0
- package/src/main.ts +273 -0
- package/src/models.json +198 -0
- package/src/transform/request.test.ts +176 -0
- package/src/transform/request.ts +102 -0
- package/src/transform/response.test.ts +75 -0
- package/src/transform/response.ts +27 -0
- package/src/transform/stream.test.ts +108 -0
- package/src/transform/stream.ts +61 -0
- package/src/types.ts +66 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { transformRequest } from "./request"
|
|
3
|
+
import { geminiCliConfig, ProviderConfig } from "../lib/services/config"
|
|
4
|
+
import { Session } from "../lib/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
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { regex } from "arkregex"
|
|
2
|
+
import { Effect, pipe } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
CODE_ASSIST_VERSION,
|
|
5
|
+
ProviderConfig,
|
|
6
|
+
type RequestContext,
|
|
7
|
+
} from "../lib/services/config"
|
|
8
|
+
import { Session } from "../lib/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("x-opencode-tools-debug", "1")
|
|
54
|
+
headers.set("Authorization", `Bearer ${accessToken}`)
|
|
55
|
+
|
|
56
|
+
for (const [key, value] of Object.entries(config.HEADERS)) {
|
|
57
|
+
headers.set(key, value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (streaming) {
|
|
61
|
+
headers.set("Accept", "text/event-stream")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Wrap and transform request
|
|
65
|
+
const isJson = typeof init?.body === "string"
|
|
66
|
+
const parsedBody = yield* pipe(
|
|
67
|
+
Effect.try(() => (isJson ? JSON.parse(init.body as string) : null)),
|
|
68
|
+
Effect.orElseSucceed(() => null),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const wrappedBody = {
|
|
72
|
+
project: projectId,
|
|
73
|
+
model,
|
|
74
|
+
request: parsedBody ?? {},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
body: transformedBody,
|
|
79
|
+
headers: finalHeaders,
|
|
80
|
+
url: finalUrl,
|
|
81
|
+
} =
|
|
82
|
+
config.transformRequest ?
|
|
83
|
+
yield* config.transformRequest({
|
|
84
|
+
body: wrappedBody,
|
|
85
|
+
headers,
|
|
86
|
+
url,
|
|
87
|
+
} satisfies RequestContext)
|
|
88
|
+
: { body: wrappedBody, headers, url }
|
|
89
|
+
|
|
90
|
+
const finalBody =
|
|
91
|
+
isJson && parsedBody ? JSON.stringify(transformedBody) : init?.body
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
input: finalUrl.toString(),
|
|
95
|
+
init: {
|
|
96
|
+
...init,
|
|
97
|
+
headers: finalHeaders,
|
|
98
|
+
body: finalBody,
|
|
99
|
+
},
|
|
100
|
+
streaming,
|
|
101
|
+
}
|
|
102
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
export type OpenCodeConfigHook = NonNullable<Hooks["config"]>
|
|
4
|
+
export type OpenCodeConfig = Parameters<OpenCodeConfigHook>[0]
|
|
5
|
+
export type OpenCodeProvider = NonNullable<OpenCodeConfig["provider"]>[string]
|
|
6
|
+
export type OpenCodeModel = NonNullable<OpenCodeProvider["models"]>[string]
|
|
7
|
+
|
|
8
|
+
export type OpenCodeAuthHook = NonNullable<Hooks["auth"]>
|
|
9
|
+
export type OpenCodeAuthLoader = NonNullable<OpenCodeAuthHook["loader"]>
|
|
10
|
+
export type OpenCodeAuthMethod = NonNullable<
|
|
11
|
+
OpenCodeAuthHook["methods"]
|
|
12
|
+
>[number]
|
|
13
|
+
|
|
14
|
+
export type OpenCodeLogLevel = NonNullable<
|
|
15
|
+
NonNullable<Parameters<PluginInput["client"]["app"]["log"]>[0]>["body"]
|
|
16
|
+
>["level"]
|
|
17
|
+
|
|
18
|
+
export type BunServeOptions = Partial<Bun.Serve.Options<undefined, never>>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Subset of google-auth-library Credentials type.
|
|
22
|
+
*
|
|
23
|
+
* Why don't they fucking use object union instead of making everything nullable.
|
|
24
|
+
*/
|
|
25
|
+
export interface Credentials {
|
|
26
|
+
access_token: string
|
|
27
|
+
refresh_token: string
|
|
28
|
+
expiry_date: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Types for models.dev, simplified
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
interface InterleavedConfig {
|
|
36
|
+
field: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Model {
|
|
40
|
+
id: string
|
|
41
|
+
name: string
|
|
42
|
+
family: string
|
|
43
|
+
attachment: boolean
|
|
44
|
+
reasoning: boolean
|
|
45
|
+
tool_call: boolean
|
|
46
|
+
temperature: boolean
|
|
47
|
+
knowledge?: string
|
|
48
|
+
release_date?: string
|
|
49
|
+
last_updated?: string
|
|
50
|
+
open_weights: boolean
|
|
51
|
+
structured_output?: boolean
|
|
52
|
+
interleaved?: boolean | InterleavedConfig
|
|
53
|
+
status?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Provider {
|
|
57
|
+
id: string
|
|
58
|
+
env: string[]
|
|
59
|
+
npm: string
|
|
60
|
+
name: string
|
|
61
|
+
doc: string
|
|
62
|
+
models: Record<string, Model>
|
|
63
|
+
api?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ModelsDev = Record<string, Provider>
|