Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Packages/OsaurusCore/Models/Configuration/ProviderPresets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case xai
case venice
case openrouter
case azure
case custom

var id: String { rawValue }
Expand All @@ -30,6 +31,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case .xai: return "xAI"
case .venice: return "Venice AI"
case .openrouter: return "OpenRouter"
case .azure: return "Azure OpenAI"
case .custom: return "Custom"
}
}
Expand All @@ -43,6 +45,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case .xai: return "Grok models"
case .venice: return "Privacy-first AI"
case .openrouter: return "Multi-provider"
case .azure: return "Azure AI Foundry"
case .custom: return "Custom endpoint"
}
}
Expand All @@ -56,6 +59,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case .xai: return "bolt.fill"
case .venice: return "lock.shield.fill"
case .openrouter: return "arrow.triangle.branch"
case .azure: return "cloud.fill"
case .custom: return "slider.horizontal.3"
}
}
Expand All @@ -69,6 +73,8 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case .xai: return [Color(red: 0.1, green: 0.1, blue: 0.1), Color(red: 0.2, green: 0.2, blue: 0.2)]
case .venice: return [Color(red: 0.83, green: 0.66, blue: 0.33), Color(red: 0.72, green: 0.53, blue: 0.17)]
case .openrouter: return [Color(red: 0.95, green: 0.55, blue: 0.25), Color(red: 0.85, green: 0.4, blue: 0.2)]
// Azure blue brand colors
case .azure: return [Color(red: 0.0, green: 0.47, blue: 0.84), Color(red: 0.0, green: 0.35, blue: 0.69)]
case .custom: return [Color(red: 0.55, green: 0.55, blue: 0.6), Color(red: 0.4, green: 0.4, blue: 0.45)]
}
}
Expand All @@ -82,6 +88,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
case .xai: return "https://console.x.ai/"
case .venice: return "https://venice.ai/settings/api"
case .openrouter: return "https://openrouter.ai/keys"
case .azure: return "https://portal.azure.com/#blade/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/OpenAI"
case .custom: return ""
}
}
Expand All @@ -98,6 +105,7 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
var documentationURL: String? {
switch self {
case .venice: return "https://docs.venice.ai"
case .azure: return "https://learn.microsoft.com/en-us/azure/ai-services/openai/"
default: return nil
}
}
Expand All @@ -121,6 +129,13 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
"Generate a new API key",
"Copy and paste it here",
]
case .azure:
return [
"Open Azure Portal and go to your OpenAI resource",
"Click \"Keys and Endpoint\" in the left menu",
"Copy KEY 1 or KEY 2",
"Paste it here — also set your resource name and deployment",
]
default:
return [
"Go to \(name) console",
Expand Down Expand Up @@ -204,6 +219,19 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
authType: .apiKey,
providerType: .openaiLegacy
)
case .azure:
// Host is intentionally empty: the user must supply their Azure resource name.
// The actual host will be "<resource>.openai.azure.com".
// basePath is unused for Azure — the full URL is constructed from azureDeploymentName.
return ProviderPresetConfiguration(
name: "Azure OpenAI",
host: "",
providerProtocol: .https,
port: nil,
basePath: "/",
authType: .apiKey,
providerType: .azureOpenAI
)
case .custom:
return ProviderPresetConfiguration(
name: "",
Expand All @@ -222,6 +250,10 @@ enum ProviderPreset: String, CaseIterable, Identifiable {
/// Attempts to match an existing RemoteProvider to a known preset by host.
static func matching(provider: RemoteProvider) -> ProviderPreset? {
let host = provider.host.lowercased().trimmingCharacters(in: .whitespaces)
// Match Azure by provider type (host varies per resource name)
if provider.providerType == .azureOpenAI {
return .azure
}
return knownPresets.first { preset in
preset.configuration.host.lowercased() == host
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
case openResponses = "openResponses" // Open Responses API — used for official OpenAI and any compatible provider
case gemini = "gemini" // Google Gemini API
case osaurus = "osaurus" // Native Osaurus agent — full server-side execution via /agents/{id}/run
// Azure OpenAI uses OpenAI-compatible request/response format but requires:
// - `api-key` header instead of `Authorization: Bearer`
// - URL pattern: https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=<version>
case azureOpenAI = "azureOpenAI"

public var displayName: String {
switch self {
Expand All @@ -47,6 +51,7 @@ public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
case .openResponses: return "Open Responses"
case .gemini: return "Google Gemini"
case .osaurus: return "Osaurus Agent"
case .azureOpenAI: return "Azure OpenAI"
}
}

Expand All @@ -57,6 +62,7 @@ public enum RemoteProviderType: String, Codable, Sendable, CaseIterable {
case .openResponses: return "/responses"
case .gemini: return "/models" // Actual URL is built dynamically: /models/{model}:generateContent
case .osaurus: return "/run" // Unused — full URL built by RemoteProviderService.buildURLRequest
case .azureOpenAI: return "/chat/completions" // Unused — full URL built by RemoteProviderService.buildURLRequest
}
}

Expand Down Expand Up @@ -89,10 +95,28 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
/// The UUID of the agent on the remote Osaurus server. Only used when providerType == .osaurus.
public var remoteAgentId: UUID?

// MARK: - Azure OpenAI specific fields

/// Azure OpenAI deployment name (replaces model in the URL path).
/// Only used when providerType == .azureOpenAI.
/// Example: "gpt-4o-deployment"
public var azureDeploymentName: String?

/// Azure OpenAI API version query parameter.
/// Only used when providerType == .azureOpenAI.
/// Defaults to "2024-02-01" if not set.
public var azureAPIVersion: String?

/// Resolved Azure API version, falling back to the stable default.
public var resolvedAzureAPIVersion: String {
azureAPIVersion ?? "2024-02-01"
}

private enum CodingKeys: String, CodingKey {
case id, name, host, providerProtocol, port, basePath
case customHeaders, authType, providerType, enabled, autoConnect, timeout
case secretHeaderKeys, remoteAgentId
case azureDeploymentName, azureAPIVersion
}

public init(
Expand All @@ -109,7 +133,9 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
autoConnect: Bool = true,
timeout: TimeInterval = 60,
secretHeaderKeys: [String] = [],
remoteAgentId: UUID? = nil
remoteAgentId: UUID? = nil,
azureDeploymentName: String? = nil,
azureAPIVersion: String? = nil
) {
self.id = id
self.name = name
Expand All @@ -125,6 +151,8 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
self.timeout = timeout
self.secretHeaderKeys = secretHeaderKeys
self.remoteAgentId = remoteAgentId
self.azureDeploymentName = azureDeploymentName
self.azureAPIVersion = azureAPIVersion
}

/// Custom decoder – uses `decodeIfPresent` for backward compatibility with older config files.
Expand All @@ -146,6 +174,8 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
timeout = try container.decodeIfPresent(TimeInterval.self, forKey: .timeout) ?? 60
secretHeaderKeys = try container.decodeIfPresent([String].self, forKey: .secretHeaderKeys) ?? []
remoteAgentId = try container.decodeIfPresent(UUID.self, forKey: .remoteAgentId)
azureDeploymentName = try container.decodeIfPresent(String.self, forKey: .azureDeploymentName)
azureAPIVersion = try container.decodeIfPresent(String.self, forKey: .azureAPIVersion)
}

/// Get the effective port (uses protocol default if not specified)
Expand Down Expand Up @@ -210,6 +240,57 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
return URL(string: base.absoluteString + normalizedEndpoint)
}

/// Build the full Azure OpenAI chat completions URL for the configured deployment.
///
/// Format: `https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=<version>`
///
/// - Returns: The fully constructed URL, or `nil` when `azureDeploymentName` is not set
/// or the host cannot be resolved.
public func azureChatCompletionsURL() -> URL? {
guard let deployment = azureDeploymentName, !deployment.isEmpty else { return nil }

// Build: scheme://host/openai/deployments/<deployment>/chat/completions?api-version=<version>
var components = URLComponents()
components.scheme = providerProtocol.rawValue

// Strip any path from host — Azure resource names never include a path component.
var actualHost = host.trimmingCharacters(in: .whitespaces)
if let slashIndex = actualHost.firstIndex(of: "/") {
actualHost = String(actualHost[..<slashIndex])
}
components.host = actualHost

if let port = port, port != providerProtocol.defaultPort {
components.port = port
}

components.path = "/openai/deployments/\(deployment)/chat/completions"
components.queryItems = [URLQueryItem(name: "api-version", value: resolvedAzureAPIVersion)]
return components.url
}

/// Build the Azure OpenAI models list URL.
///
/// Format: `https://<resource>.openai.azure.com/openai/models?api-version=<version>`
public func azureModelsURL() -> URL? {
var components = URLComponents()
components.scheme = providerProtocol.rawValue

var actualHost = host.trimmingCharacters(in: .whitespaces)
if let slashIndex = actualHost.firstIndex(of: "/") {
actualHost = String(actualHost[..<slashIndex])
}
components.host = actualHost

if let port = port, port != providerProtocol.defaultPort {
components.port = port
}

components.path = "/openai/models"
components.queryItems = [URLQueryItem(name: "api-version", value: resolvedAzureAPIVersion)]
return components.url
}

/// Display string for the endpoint
public var displayEndpoint: String {
// Use the baseURL to get the properly constructed endpoint
Expand Down Expand Up @@ -251,6 +332,11 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable {
if headers["x-goog-api-key"] == nil {
headers["x-goog-api-key"] = apiKey
}
case .azureOpenAI:
// Azure uses `api-key` header instead of `Authorization: Bearer`
if headers["api-key"] == nil {
headers["api-key"] = apiKey
}
case .openaiLegacy, .openResponses, .osaurus:
if headers["Authorization"] == nil {
headers["Authorization"] = "Bearer \(apiKey)"
Expand Down
61 changes: 61 additions & 0 deletions Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,12 @@ public actor RemoteProviderService: ToolCapableService {
throw RemoteProviderServiceError.invalidURL
}
url = agentURL
} else if provider.providerType == .azureOpenAI {
// Azure OpenAI uses deployment-based URL with api-version query parameter
guard let azureURL = provider.azureChatCompletionsURL() else {
throw RemoteProviderServiceError.invalidURL
}
url = azureURL
} else {
let endpoint = provider.providerType.chatEndpoint
guard let standardURL = provider.url(for: endpoint) else {
Expand Down Expand Up @@ -1685,6 +1691,9 @@ public actor RemoteProviderService: ToolCapableService {
case .osaurus:
// Native Osaurus agent uses OpenAI-compatible request format
bodyData = try encoder.encode(request)
case .azureOpenAI:
// Azure OpenAI uses the same request format as OpenAI /chat/completions
bodyData = try encoder.encode(request)
}
urlRequest.httpBody = bodyData
return urlRequest
Expand Down Expand Up @@ -1789,6 +1798,13 @@ public actor RemoteProviderService: ToolCapableService {
let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
let content = response.choices.first?.message.content
return (content, nil)

case .azureOpenAI:
// Azure OpenAI returns standard OpenAI-compatible chat completion responses
let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
let content = response.choices.first?.message.content
let toolCalls = response.choices.first?.message.tool_calls
return (content, toolCalls)
}
}

Expand Down Expand Up @@ -2442,6 +2458,11 @@ extension RemoteProviderService {
return try await fetchOsaurusModels(from: provider)
}

// Azure OpenAI uses its own models endpoint with api-version query param
if provider.providerType == .azureOpenAI {
return try await fetchAzureOpenAIModels(from: provider)
}

// OpenAI-compatible providers use /models endpoint
guard let url = provider.url(for: "/models") else {
throw RemoteProviderServiceError.invalidURL
Expand Down Expand Up @@ -2514,6 +2535,46 @@ extension RemoteProviderService {
}

/// Fetch models from Gemini API (different response format from OpenAI)

/// Fetch available deployments from Azure OpenAI.
///
/// Azure OpenAI exposes deployments (not raw models) via:
/// GET https://<resource>.openai.azure.com/openai/models?api-version=<version>
///
/// The response follows the standard OpenAI /models format, so we reuse ModelsResponse.
private static func fetchAzureOpenAIModels(from provider: RemoteProvider) async throws -> [String] {
guard let url = provider.azureModelsURL() else {
throw RemoteProviderServiceError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")

// Add provider headers — includes the api-key header resolved by resolvedHeaders()
for (key, value) in provider.resolvedHeaders() {
request.setValue(value, forHTTPHeaderField: key)
}

let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = min(provider.timeout, 30)
let session = URLSession(configuration: config)

let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw RemoteProviderServiceError.invalidResponse
}

if httpResponse.statusCode >= 400 {
let errorMessage = extractErrorMessage(from: data, statusCode: httpResponse.statusCode)
throw RemoteProviderServiceError.requestFailed(errorMessage)
}

let modelsResponse = try JSONDecoder().decode(ModelsResponse.self, from: data)
return modelsResponse.data.map { /usr/bin/bash.id }
}

private static func fetchGeminiModels(from provider: RemoteProvider) async throws -> [String] {
guard let url = provider.url(for: "/models") else {
throw RemoteProviderServiceError.invalidURL
Expand Down
Loading