Skip to content

Commit 6bf9a16

Browse files
committed
fix: support Azure OpenAI Foundry providers
1 parent 409ca03 commit 6bf9a16

6 files changed

Lines changed: 573 additions & 34 deletions

File tree

Packages/OsaurusCore/Managers/RemoteProviderManager.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,18 @@ public final class RemoteProviderManager: ObservableObject {
237237
RemoteProviderKeychain.saveOAuthTokens(refreshed, for: provider.id)
238238
}
239239

240-
// Fetch models from the provider
241-
let models = try await RemoteProviderService.fetchModels(from: provider)
240+
// Fetch models from the provider and merge any manually configured deployment IDs.
241+
let discoveredModels: [String]
242+
do {
243+
discoveredModels = try await RemoteProviderService.fetchModels(from: provider)
244+
} catch {
245+
if provider.providerType == .azureOpenAI && !provider.manualModelIds.isEmpty {
246+
discoveredModels = []
247+
} else {
248+
throw error
249+
}
250+
}
251+
let models = provider.mergedModelIds(discovered: discoveredModels)
242252

243253
// Create service instance – resolve headers eagerly on @MainActor
244254
// so the service actor never reads from Keychain off the main thread.
@@ -432,6 +442,10 @@ public final class RemoteProviderManager: ObservableObject {
432442
if testHeaders["x-goog-api-key"] == nil {
433443
testHeaders["x-goog-api-key"] = apiKey
434444
}
445+
case .azureOpenAI:
446+
if testHeaders["api-key"] == nil {
447+
testHeaders["api-key"] = apiKey
448+
}
435449
case .openaiLegacy, .openResponses, .openAICodex, .osaurus:
436450
if testHeaders["Authorization"] == nil {
437451
testHeaders["Authorization"] = "Bearer \(apiKey)"

Packages/OsaurusCore/Models/Configuration/ProviderPresets.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import SwiftUI
1212
/// Unified provider presets shared across onboarding and provider management.
1313
enum ProviderPreset: String, CaseIterable, Identifiable {
1414
case anthropic
15+
case azureOpenAI
1516
case openai
1617
case google
1718
case xai
@@ -25,6 +26,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
2526
var name: String {
2627
switch self {
2728
case .anthropic: return "Anthropic"
29+
case .azureOpenAI: return "Azure OpenAI Foundry"
2830
case .openai: return "OpenAI"
2931
case .google: return "Google"
3032
case .xai: return "xAI"
@@ -38,6 +40,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
3840
var description: String {
3941
switch self {
4042
case .anthropic: return "Claude models"
43+
case .azureOpenAI: return "Azure deployments"
4144
case .openai: return "ChatGPT/Codex or Platform API"
4245
case .google: return "Gemini models"
4346
case .xai: return "Grok models"
@@ -51,6 +54,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
5154
var icon: String {
5255
switch self {
5356
case .anthropic: return "brain.head.profile"
57+
case .azureOpenAI: return "cloud.fill"
5458
case .openai: return "sparkles"
5559
case .google: return "globe"
5660
case .xai: return "bolt.fill"
@@ -64,6 +68,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
6468
var gradient: [Color] {
6569
switch self {
6670
case .anthropic: return [Color(red: 0.85, green: 0.55, blue: 0.35), Color(red: 0.75, green: 0.4, blue: 0.25)]
71+
case .azureOpenAI: return [Color(red: 0.0, green: 0.47, blue: 0.84), Color(red: 0.0, green: 0.62, blue: 0.72)]
6772
case .openai: return [Color(red: 0.0, green: 0.65, blue: 0.52), Color(red: 0.0, green: 0.5, blue: 0.4)]
6873
case .google: return [Color(red: 0.26, green: 0.52, blue: 0.96), Color(red: 0.18, green: 0.38, blue: 0.85)]
6974
case .xai: return [Color(red: 0.1, green: 0.1, blue: 0.1), Color(red: 0.2, green: 0.2, blue: 0.2)]
@@ -77,6 +82,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
7782
var consoleURL: String {
7883
switch self {
7984
case .anthropic: return "https://console.anthropic.com/settings/keys"
85+
case .azureOpenAI: return "https://ai.azure.com"
8086
case .openai: return "https://platform.openai.com/api-keys"
8187
case .google: return "https://aistudio.google.com/apikey"
8288
case .xai: return "https://console.x.ai/"
@@ -89,6 +95,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
8995
/// Optional badge label (e.g. "Privacy") shown as a highlight pill on provider cards
9096
var badge: String? {
9197
switch self {
98+
case .azureOpenAI: return "Azure"
9299
case .venice: return "Privacy"
93100
default: return nil
94101
}
@@ -97,6 +104,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
97104
/// Optional documentation URL for the provider (shown in help sections)
98105
var documentationURL: String? {
99106
switch self {
107+
case .azureOpenAI: return "https://learn.microsoft.com/azure/ai-foundry/openai/"
100108
case .venice: return "https://docs.venice.ai"
101109
default: return nil
102110
}
@@ -114,6 +122,13 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
114122
/// Help steps shown when guiding the user to create an API key
115123
var helpSteps: [String] {
116124
switch self {
125+
case .azureOpenAI:
126+
return [
127+
"Open your Azure OpenAI resource in Azure AI Foundry",
128+
"Copy the resource endpoint host and an API key",
129+
"Add deployment names if they do not appear automatically",
130+
"Paste the key here",
131+
]
117132
case .openai:
118133
return [
119134
"Go to the OpenAI Platform API keys page",
@@ -161,6 +176,16 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
161176
authType: .apiKey,
162177
providerType: .anthropic
163178
)
179+
case .azureOpenAI:
180+
return ProviderPresetConfiguration(
181+
name: "Azure OpenAI Foundry",
182+
host: "",
183+
providerProtocol: .https,
184+
port: nil,
185+
basePath: "/openai/v1",
186+
authType: .apiKey,
187+
providerType: .azureOpenAI
188+
)
164189
case .openai:
165190
return ProviderPresetConfiguration(
166191
name: "OpenAI",
@@ -228,9 +253,14 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
228253

229254
/// Attempts to match an existing RemoteProvider to a known preset by host.
230255
static func matching(provider: RemoteProvider) -> ProviderPreset? {
256+
if provider.providerType == .azureOpenAI {
257+
return .azureOpenAI
258+
}
259+
231260
let host = provider.host.lowercased().trimmingCharacters(in: .whitespaces)
232261
return knownPresets.first { preset in
233-
preset.configuration.host.lowercased() == host
262+
guard !preset.configuration.host.isEmpty else { return false }
263+
return preset.configuration.host.lowercased() == host
234264
}
235265
}
236266
}

Packages/OsaurusCore/Models/Configuration/RemoteProviderConfiguration.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public enum RemoteProviderAuthType: String, Codable, Sendable, CaseIterable {
3636
/// Type of remote provider (determines API format)
3737
public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
3838
case openaiLegacy = "openai" // OpenAI-compatible /chat/completions (third-party servers, backward compat)
39+
case azureOpenAI = "azureOpenAI" // Azure OpenAI Foundry /openai/v1 OpenAI-compatible chat completions
3940
case anthropic = "anthropic" // Anthropic Messages API
4041
case openResponses = "openResponses" // Open Responses API — used for official OpenAI and any compatible provider
4142
case openAICodex = "openAICodex" // ChatGPT/Codex OAuth backend
@@ -45,6 +46,7 @@ public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
4546
public var displayName: String {
4647
switch self {
4748
case .openaiLegacy: return L("OpenAI Compatible")
49+
case .azureOpenAI: return L("Azure OpenAI Foundry")
4850
case .anthropic: return L("Anthropic")
4951
case .openResponses: return L("Open Responses")
5052
case .openAICodex: return L("OpenAI Codex")
@@ -55,7 +57,7 @@ public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
5557

5658
public var chatEndpoint: String {
5759
switch self {
58-
case .openaiLegacy: return "/chat/completions"
60+
case .openaiLegacy, .azureOpenAI: return "/chat/completions"
5961
case .anthropic: return "/messages"
6062
case .openResponses: return "/responses"
6163
case .openAICodex: return "/codex/responses"
@@ -86,6 +88,7 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
8688
public var enabled: Bool
8789
public var autoConnect: Bool
8890
public var timeout: TimeInterval
91+
public var manualModelIds: [String]
8992

9093
// Keys for headers that should be stored in Keychain (not persisted in config)
9194
public var secretHeaderKeys: [String]
@@ -100,6 +103,7 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
100103
private enum CodingKeys: String, CodingKey {
101104
case id, name, host, providerProtocol, port, basePath
102105
case customHeaders, authType, providerType, enabled, autoConnect, timeout
106+
case manualModelIds
103107
case secretHeaderKeys, remoteAgentId, remoteAgentAddress
104108
}
105109

@@ -116,6 +120,7 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
116120
enabled: Bool = true,
117121
autoConnect: Bool = true,
118122
timeout: TimeInterval = 60,
123+
manualModelIds: [String] = [],
119124
secretHeaderKeys: [String] = [],
120125
remoteAgentId: UUID? = nil,
121126
remoteAgentAddress: String? = nil
@@ -132,6 +137,7 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
132137
self.enabled = enabled
133138
self.autoConnect = autoConnect
134139
self.timeout = timeout
140+
self.manualModelIds = manualModelIds
135141
self.secretHeaderKeys = secretHeaderKeys
136142
self.remoteAgentId = remoteAgentId
137143
self.remoteAgentAddress = remoteAgentAddress
@@ -154,6 +160,7 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
154160
enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
155161
autoConnect = try container.decodeIfPresent(Bool.self, forKey: .autoConnect) ?? true
156162
timeout = try container.decodeIfPresent(TimeInterval.self, forKey: .timeout) ?? 60
163+
manualModelIds = try container.decodeIfPresent([String].self, forKey: .manualModelIds) ?? []
157164
secretHeaderKeys = try container.decodeIfPresent([String].self, forKey: .secretHeaderKeys) ?? []
158165
remoteAgentId = try container.decodeIfPresent(UUID.self, forKey: .remoteAgentId)
159166
remoteAgentAddress = try container.decodeIfPresent(String.self, forKey: .remoteAgentAddress)
@@ -262,6 +269,10 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
262269
if headers["x-goog-api-key"] == nil {
263270
headers["x-goog-api-key"] = apiKey
264271
}
272+
case .azureOpenAI:
273+
if headers["api-key"] == nil {
274+
headers["api-key"] = apiKey
275+
}
265276
case .openaiLegacy, .openResponses, .openAICodex, .osaurus:
266277
if headers["Authorization"] == nil {
267278
headers["Authorization"] = "Bearer \(apiKey)"
@@ -289,6 +300,25 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
289300
public func getOAuthTokens() -> RemoteProviderOAuthTokens? {
290301
RemoteProviderKeychain.getOAuthTokens(for: id)
291302
}
303+
304+
public func mergedModelIds(discovered: [String]) -> [String] {
305+
var seen = Set<String>()
306+
var merged: [String] = []
307+
let sourceModels = providerType == .azureOpenAI ? manualModelIds : discovered + manualModelIds
308+
309+
for rawValue in sourceModels {
310+
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
311+
guard !value.isEmpty else { continue }
312+
313+
let key = value.lowercased()
314+
guard !seen.contains(key) else { continue }
315+
316+
seen.insert(key)
317+
merged.append(value)
318+
}
319+
320+
return merged
321+
}
292322
}
293323

294324
// MARK: - Remote Provider Runtime State

0 commit comments

Comments
 (0)