Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ concurrency:
jobs:
test-ios:
name: Build & Test (iOS)
runs-on: macos-26
runs-on: xcode-27
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -92,4 +92,4 @@ jobs:
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
| xcpretty && exit ${PIPESTATUS[0]}
| xcpretty && exit ${PIPESTATUS[0]}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## [1.7.15-build-117] - 2026-09-24

### Fixed

- Image generation with GPT Image models no longer fails because of an unsupported response format parameter

## [1.7.10-build-115] - 2026-09-19

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<img src="https://img.shields.io/badge/License-AGPL%20v3.0-blue?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Platform-iOS%2026+%20|%20iPadOS%2026+%20|%20macOS%2026+-blue?style=flat-square" alt="Platform" />
<img src="https://img.shields.io/badge/Swift-6+-orange?style=flat-square&logo=swift" alt="Swift" />
<img src="https://img.shields.io/badge/Version-1.7.10-brightgreen?style=flat-square" alt="Version 1.7.10" />
<img src="https://img.shields.io/badge/Version-1.7.15-brightgreen?style=flat-square" alt="Version 1.7.15" />
</p>

OpenClient connects directly to the AI server you configure, without an OpenClient-hosted proxy or subscription.
Expand Down
1 change: 1 addition & 0 deletions TestFlight/WhatToTest.en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Hi there! We've got some great new features for you in this update.
• Announcements on the Home screen now animate into view on Mac with the same smooth transition used on iPhone and iPad.
• Your conversation list no longer shows a duplicate separator above your chats.
• The Models screen no longer repeats requests while loading, so it stays responsive.
• Image generation now works with GPT Image models through LiteLLM, without requests failing because of an unsupported setting.
• Minor bug fixes and improvements for a smoother experience.

Thanks for your continued support and for helping us build the best possible LLM client together.
Expand Down
1 change: 1 addition & 0 deletions TestFlight/WhatToTest.es-ES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
• Los anuncios de la pantalla de Inicio ahora aparecen con una animación fluida en Mac, con la misma transición que en iPhone e iPad.
• La lista de conversaciones ya no muestra un separador duplicado encima de tus chats.
• La pantalla de Modelos ya no repite solicitudes mientras se carga, para mantenerse ágil.
• La generación de imágenes ya funciona con modelos GPT Image a través de LiteLLM, sin que las solicitudes fallen por un ajuste no compatible.
• Pequeñas correcciones de errores y mejoras para disfrutar de una experiencia más fluida.

Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import Observation
import XCTest
@testable import openclient_llm

Expand All @@ -21,10 +22,12 @@ extension ConversationListViewModelTests {
]
mockLoadConversations.result = .success(conversations)
mockFetchModels.result = .success([LLMModel(id: "gpt-4")])
let modelsLoaded = expectation(description: "Available models loaded")

// When
sut.send(.viewAppeared)
for _ in 0..<10 { await Task.yield() }
Self.observeModelsLoaded(sut, expectation: modelsLoaded)
await fulfillment(of: [modelsLoaded], timeout: 2)

// Then
guard case .loaded(let loadedState) = sut.state else {
Expand Down Expand Up @@ -94,3 +97,22 @@ extension ConversationListViewModelTests {
XCTAssertTrue(loadedState.availableModels.isEmpty)
}
}

// MARK: - Private

private extension ConversationListViewModelTests {
static func observeModelsLoaded(_ sut: ConversationListViewModel, expectation: XCTestExpectation) {
if case .loaded(let loadedState) = sut.state, !loadedState.availableModels.isEmpty {
expectation.fulfill()
return
}
withObservationTracking {
_ = sut.state
} onChange: { [weak sut] in
Task { @MainActor in
guard let sut else { return }
observeModelsLoaded(sut, expectation: expectation)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,55 @@ final class ImageGenerationRepositoryTests: XCTestCase {
XCTAssertEqual(apiClient.lastRequestEndpoint, "images/generations")
XCTAssertEqual(apiClient.lastRequestTimeoutInterval, 600)
XCTAssertNil(apiClient.lastMultipartEndpoint)
let body = try XCTUnwrap(apiClient.lastRequestBody)
let json = try JSONEncoder().encode(body)
let fields = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any])
XCTAssertEqual(fields["model"] as? String, "gpt-image-2")
XCTAssertEqual(fields["prompt"] as? String, "A cat")
XCTAssertEqual(fields["n"] as? Int, 1)
XCTAssertNil(fields["response_format"])
}

func test_generateImage_gptImageAndAlias_omitUnsupportedResponseFormat() async throws {
for model in ["gpt-image-1.5", "custom-image-alias"] {
// Given
let apiClient = MockAPIClient()
apiClient.requestResult = ImageGenerationResponse(data: [
.init(url: nil, b64Json: Data([1, 2, 3]).base64EncodedString(), revisedPrompt: nil)
])
let sut = ImageGenerationRepository(apiClient: apiClient)

// When
_ = try await sut.generateImage(prompt: "A cat", model: model, images: [])

// Then
let body = try XCTUnwrap(apiClient.lastRequestBody)
let json = try JSONEncoder().encode(body)
let fields = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any])
XCTAssertEqual(fields["model"] as? String, model)
XCTAssertNil(fields["response_format"])
}
}

func test_generateImage_dallEModels_requestBase64ResponseFormat() async throws {
for model in ["dall-e-2", "dall-e-3"] {
// Given
let apiClient = MockAPIClient()
apiClient.requestResult = ImageGenerationResponse(data: [
.init(url: nil, b64Json: Data([1, 2, 3]).base64EncodedString(), revisedPrompt: nil)
])
let sut = ImageGenerationRepository(apiClient: apiClient)

// When
_ = try await sut.generateImage(prompt: "A cat", model: model, images: [])

// Then
let body = try XCTUnwrap(apiClient.lastRequestBody)
let json = try JSONEncoder().encode(body)
let fields = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any])
XCTAssertEqual(fields["model"] as? String, model)
XCTAssertEqual(fields["response_format"] as? String, "b64_json")
}
}

func test_generateImage_multipleImages_sendsOrderedMultipartFilesAndFields() async throws {
Expand Down Expand Up @@ -87,14 +136,20 @@ final class ImageGenerationRepositoryTests: XCTestCase {
let sut = ImageGenerationRepository(apiClient: apiClient)

// When
let result = try await sut.generateImage(prompt: "A cat", model: "gpt-image-2", images: images)
let result = try await sut.generateImage(prompt: "A cat", model: "custom-image-alias", images: images)

// Then
XCTAssertEqual(result.data, Data([9, 8, 7]))
XCTAssertEqual(result.mimeType, "image/webp")
XCTAssertEqual(result.revisedPrompt, "A blue cat")
XCTAssertEqual(apiClient.lastRequestEndpoint, images.isEmpty ? "images/generations" : nil)
XCTAssertEqual(apiClient.lastMultipartEndpoint, images.isEmpty ? nil : "images/edits")
if images.isEmpty {
let body = try XCTUnwrap(apiClient.lastRequestBody)
let json = try JSONEncoder().encode(body)
let fields = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any])
XCTAssertNil(fields["response_format"])
}
}
}

Expand Down
44 changes: 22 additions & 22 deletions openclient-llm.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -791,8 +791,8 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.7.10;
MACOSX_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.macos-widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -838,8 +838,8 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.7.10;
MACOSX_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.macos-widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -1010,12 +1010,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -1059,12 +1059,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -1122,8 +1122,8 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.7.10;
MACOSX_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = OpenClient;
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -1179,8 +1179,8 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.7.10;
MACOSX_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = OpenClient;
REGISTER_APP_GROUPS = YES;
Expand All @@ -1203,7 +1203,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = Q4X55987WE;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm-test";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -1226,7 +1226,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = Q4X55987WE;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm-test";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -1252,13 +1252,13 @@
INFOPLIST_FILE = ShareExtension/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClient;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Arturo Carretero Calvo. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand All @@ -1283,13 +1283,13 @@
INFOPLIST_FILE = ShareExtension/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClient;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Arturo Carretero Calvo. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand All @@ -1316,13 +1316,13 @@
INFOPLIST_FILE = "WidgetsExtension-iOS/Resources/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = OpenClient;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Arturo Carretero Calvo. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand All @@ -1349,13 +1349,13 @@
INFOPLIST_FILE = "WidgetsExtension-iOS/Resources/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = OpenClient;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Arturo Carretero Calvo. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.7.10;
MARKETING_VERSION = 1.7.15;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ nonisolated struct ImageGenerationRequest: Encodable, Sendable {
let model: String
let prompt: String
let numberOfImages: Int
let responseFormat: String
let responseFormat: String?

enum CodingKeys: String, CodingKey {
case model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ struct ImageGenerationRepository: ImageGenerationRepositoryProtocol {
model: model,
prompt: prompt,
numberOfImages: 1,
responseFormat: "b64_json"
responseFormat: ["dall-e-2", "dall-e-3"].contains(model) ? "b64_json" : nil
)
return try await apiClient.request(
endpoint: "images/generations",
Expand Down
2 changes: 2 additions & 0 deletions specs/litellm-api.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ description: "Use when changing OpenAI-compatible or LiteLLM networking, model d
- Dedicated `.imageGeneration` models use `POST /images/generations`; the existing dedicated edit flow uses multipart
`POST /images/edits`. Dedicated responses may contain bounded base64 data or an HTTP(S) URL handled by the existing image
repository.
- Dedicated generation requests include `response_format: "b64_json"` only for explicit `dall-e-2` and `dall-e-3` IDs;
omit it for GPT image models and unknown or aliased IDs, which may return base64 data or an HTTP(S) URL.
- Chat image generation accepts text only, returns the first native image, supplies no tools, and has no fallback to a
dedicated endpoint. Dedicated generation and editing must not be described as the same capability.
- Generated output is capped at 25 MiB. Preserve typed image data out of model-facing tool text and persist it according to
Expand Down