Skip to content

Commit 7924f21

Browse files
committed
Add OpenAIResponsesRawService
1 parent 7afeed6 commit 7924f21

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import AIModel
2+
import AsyncAlgorithms
3+
import ChatBasic
4+
import Foundation
5+
import GitHubCopilotService
6+
import JoinJSON
7+
import Logger
8+
import Preferences
9+
10+
/// https://platform.openai.com/docs/api-reference/responses/create
11+
public actor OpenAIResponsesRawService {
12+
struct CompletionAPIError: Error, Decodable, LocalizedError {
13+
struct ErrorDetail: Decodable {
14+
var message: String
15+
var type: String?
16+
var param: String?
17+
var code: String?
18+
}
19+
20+
struct MistralAIErrorMessage: Decodable {
21+
struct Detail: Decodable {
22+
var msg: String?
23+
}
24+
25+
var message: String?
26+
var msg: String?
27+
var detail: [Detail]?
28+
}
29+
30+
enum Message {
31+
case raw(String)
32+
case mistralAI(MistralAIErrorMessage)
33+
}
34+
35+
var error: ErrorDetail?
36+
var message: Message
37+
38+
var errorDescription: String? {
39+
if let message = error?.message { return message }
40+
switch message {
41+
case let .raw(string):
42+
return string
43+
case let .mistralAI(mistralAIErrorMessage):
44+
return mistralAIErrorMessage.message
45+
?? mistralAIErrorMessage.msg
46+
?? mistralAIErrorMessage.detail?.first?.msg
47+
?? "Unknown Error"
48+
}
49+
}
50+
51+
enum CodingKeys: String, CodingKey {
52+
case error
53+
case message
54+
}
55+
56+
init(from decoder: Decoder) throws {
57+
let container = try decoder.container(keyedBy: CodingKeys.self)
58+
59+
error = try container.decode(ErrorDetail.self, forKey: .error)
60+
message = {
61+
if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) {
62+
return CompletionAPIError.Message.mistralAI(e)
63+
}
64+
if let e = try? container.decode(String.self, forKey: .message) {
65+
return .raw(e)
66+
}
67+
return .raw("Unknown Error")
68+
}()
69+
}
70+
}
71+
72+
var apiKey: String
73+
var endpoint: URL
74+
var requestBody: [String: Any]
75+
var model: ChatModel
76+
let requestModifier: ((inout URLRequest) -> Void)?
77+
78+
public init(
79+
apiKey: String,
80+
model: ChatModel,
81+
endpoint: URL,
82+
requestBody: Data,
83+
requestModifier: ((inout URLRequest) -> Void)? = nil
84+
) {
85+
self.apiKey = apiKey
86+
self.endpoint = endpoint
87+
self.requestBody = (
88+
try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any]
89+
) ?? [:]
90+
self.requestBody["model"] = model.info.modelName
91+
self.model = model
92+
self.requestModifier = requestModifier
93+
}
94+
95+
public func callAsFunction() async throws
96+
-> URLSession.AsyncBytes
97+
{
98+
requestBody["stream"] = true
99+
var request = URLRequest(url: endpoint)
100+
request.httpMethod = "POST"
101+
request.httpBody = try JSONSerialization.data(
102+
withJSONObject: requestBody,
103+
options: []
104+
)
105+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
106+
107+
Self.setupAppInformation(&request)
108+
await Self.setupAPIKey(&request, model: model, apiKey: apiKey)
109+
Self.setupGitHubCopilotVisionField(&request, model: model)
110+
await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
111+
requestModifier?(&request)
112+
113+
let (result, response) = try await URLSession.shared.bytes(for: request)
114+
guard let response = response as? HTTPURLResponse else {
115+
throw ChatGPTServiceError.responseInvalid
116+
}
117+
118+
guard response.statusCode == 200 else {
119+
let text = try await result.lines.reduce(into: "") { partialResult, current in
120+
partialResult += current
121+
}
122+
guard let data = text.data(using: .utf8)
123+
else { throw ChatGPTServiceError.responseInvalid }
124+
if response.statusCode == 403 {
125+
throw ChatGPTServiceError.unauthorized(text)
126+
}
127+
let decoder = JSONDecoder()
128+
let error = try? decoder.decode(CompletionAPIError.self, from: data)
129+
throw error ?? ChatGPTServiceError.otherError(
130+
text +
131+
"\n\nPlease check your model settings, some capabilities may not be supported by the model."
132+
)
133+
}
134+
135+
return result
136+
}
137+
138+
public func callAsFunction() async throws -> Data {
139+
let stream: URLSession.AsyncBytes = try await callAsFunction()
140+
141+
return try await stream.reduce(into: Data()) { partialResult, byte in
142+
partialResult.append(byte)
143+
}
144+
}
145+
146+
static func setupAppInformation(_ request: inout URLRequest) {
147+
if #available(macOS 13.0, *) {
148+
if request.url?.host == "openrouter.ai" {
149+
request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title")
150+
request.setValue(
151+
"https://github.com/intitni/CopilotForXcode",
152+
forHTTPHeaderField: "HTTP-Referer"
153+
)
154+
}
155+
} else {
156+
if request.url?.host == "openrouter.ai" {
157+
request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title")
158+
request.setValue(
159+
"https://github.com/intitni/CopilotForXcode",
160+
forHTTPHeaderField: "HTTP-Referer"
161+
)
162+
}
163+
}
164+
}
165+
166+
static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) async {
167+
if !apiKey.isEmpty {
168+
switch model.format {
169+
case .openAI:
170+
if !model.info.openAIInfo.organizationID.isEmpty {
171+
request.setValue(
172+
model.info.openAIInfo.organizationID,
173+
forHTTPHeaderField: "OpenAI-Organization"
174+
)
175+
}
176+
177+
if !model.info.openAIInfo.projectID.isEmpty {
178+
request.setValue(
179+
model.info.openAIInfo.projectID,
180+
forHTTPHeaderField: "OpenAI-Project"
181+
)
182+
}
183+
184+
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
185+
case .openAICompatible:
186+
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
187+
case .azureOpenAI:
188+
request.setValue(apiKey, forHTTPHeaderField: "api-key")
189+
case .gitHubCopilot:
190+
break
191+
case .googleAI:
192+
assertionFailure("Unsupported")
193+
case .ollama:
194+
assertionFailure("Unsupported")
195+
case .claude:
196+
assertionFailure("Unsupported")
197+
}
198+
}
199+
200+
if model.format == .gitHubCopilot,
201+
let token = try? await GitHubCopilotExtension.fetchToken()
202+
{
203+
request.setValue(
204+
"Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")",
205+
forHTTPHeaderField: "Editor-Version"
206+
)
207+
request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization")
208+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
209+
request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id")
210+
request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version")
211+
}
212+
}
213+
214+
static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) {
215+
if model.info.supportsImage {
216+
request.setValue("true", forHTTPHeaderField: "copilot-vision-request")
217+
}
218+
}
219+
220+
static func setupExtraHeaderFields(
221+
_ request: inout URLRequest,
222+
model: ChatModel,
223+
apiKey: String
224+
) async {
225+
let parser = HeaderValueParser()
226+
for field in model.info.customHeaderInfo.headers where !field.key.isEmpty {
227+
let value = await parser.parse(
228+
field.value,
229+
context: .init(modelName: model.info.modelName, apiKey: apiKey)
230+
)
231+
request.setValue(value, forHTTPHeaderField: field.key)
232+
}
233+
}
234+
}
235+

0 commit comments

Comments
 (0)