Skip to content

Commit 517e0f0

Browse files
authored
Improve provider hashing for batch_chat_*() (#802)
Rather than hashing the complete provider object, we should just hash the most critical components. This trades a little safety for convenience; I don't think there are likely to be too many cases where you re-call `batch_chat()` with a subtly different provider object and this makes it more likely that you can retrieve results stored with a different version of ellmer. I've also added an escape hatch so you can use chats saved by ellmer 0.3.0 in ellmer 0.4.0 Extracted out from #799.
1 parent f941c95 commit 517e0f0

File tree

7 files changed

+169
-93
lines changed

7 files changed

+169
-93
lines changed

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ellmer (development version)
22

3+
* `batch_*()` no longer hashes properties of the provider besides the `name`, `model`, and `base_url`. This should provide some protection from accidentally reusing the same `.json` file with different providers, while still allowing you to use the same batch file across ellmer versions.
4+
* `batch_*()` have a new `ignore_hash` argument that allows you to opt out of the check if you're confident the difference only arises because ellmer itself has changed.
35
* Turns now have a `@duration` slot. The slot is `NA` for user turns and a numeric giving the total time to complete the request for assistant turns (@simonpcouch, #798).
46
* New `chat_openai_responses()` to use the new OpenAI responses API (#365).
57
* `parallel_chat_structured()` now returns a tibble, since this does a better job of printing more complex data frames (#787).

R/batch-chat.R

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
#' it will return `NULL` if the batch is not complete, and you can retrieve
3838
#' the results later by re-running `batch_chat()` when
3939
#' `batch_chat_completed()` is `TRUE`.
40+
#' @param ignore_hash If `TRUE`, will only warn rather than error when the hash
41+
#' doesn't match. You can use this if ellmer has changed the hash structure
42+
#' and you're confident that you're reusing the same inputs.
4043
#' @returns
4144
#' For `batch_chat()`, a list of [Chat] objects, one for each prompt.
4245
#' For `batch_chat_test()`, a character vector of text responses.
@@ -78,14 +81,15 @@
7881
#' data
7982
#' }
8083
#' @export
81-
batch_chat <- function(chat, prompts, path, wait = TRUE) {
84+
batch_chat <- function(chat, prompts, path, wait = TRUE, ignore_hash = FALSE) {
8285
chat <- as_chat(chat)
8386

8487
job <- BatchJob$new(
8588
chat = chat,
8689
prompts = prompts,
8790
path = path,
88-
wait = wait
91+
wait = wait,
92+
ignore_hash = ignore_hash
8993
)
9094
job$step_until_done()
9195

@@ -101,9 +105,21 @@ batch_chat <- function(chat, prompts, path, wait = TRUE) {
101105

102106
#' @export
103107
#' @rdname batch_chat
104-
batch_chat_text <- function(chat, prompts, path, wait = TRUE) {
108+
batch_chat_text <- function(
109+
chat,
110+
prompts,
111+
path,
112+
wait = TRUE,
113+
ignore_hash = FALSE
114+
) {
105115
chat <- as_chat(chat)
106-
chats <- batch_chat(chat, prompts, path, wait = wait)
116+
chats <- batch_chat(
117+
chat,
118+
prompts,
119+
path,
120+
wait = wait,
121+
ignore_hash = ignore_hash
122+
)
107123
map_chr(chats, \(chat) if (is.null(chat)) NA else chat$last_turn()@text)
108124
}
109125

@@ -116,6 +132,7 @@ batch_chat_structured <- function(
116132
path,
117133
type,
118134
wait = TRUE,
135+
ignore_hash = FALSE,
119136
convert = TRUE,
120137
include_tokens = FALSE,
121138
include_cost = FALSE
@@ -129,7 +146,8 @@ batch_chat_structured <- function(
129146
prompts = prompts,
130147
type = wrap_type_if_needed(type, needs_wrapper),
131148
path = path,
132-
wait = wait
149+
wait = wait,
150+
ignore_hash = ignore_hash
133151
)
134152
job$step_until_done()
135153
turns <- job$result_turns()
@@ -169,6 +187,7 @@ BatchJob <- R6::R6Class(
169187
user_turns = NULL,
170188
path = NULL,
171189
should_wait = TRUE,
190+
ignore_hash = FALSE,
172191
type = NULL,
173192

174193
# Internal state
@@ -184,6 +203,7 @@ BatchJob <- R6::R6Class(
184203
path,
185204
type = NULL,
186205
wait = TRUE,
206+
ignore_hash = FALSE,
187207
call = caller_env(2)
188208
) {
189209
self$provider <- chat$get_provider()
@@ -192,12 +212,14 @@ BatchJob <- R6::R6Class(
192212
user_turns <- as_user_turns(prompts, call = call)
193213
check_string(path, allow_empty = FALSE, call = call)
194214
check_bool(wait, call = call)
215+
check_bool(ignore_hash, call = call)
195216

196217
self$chat <- chat
197218
self$user_turns <- user_turns
198219
self$type <- type
199220
self$path <- path
200221
self$should_wait <- wait
222+
self$ignore_hash <- ignore_hash
201223

202224
if (file.exists(path)) {
203225
state <- jsonlite::read_json(path)
@@ -332,25 +354,31 @@ BatchJob <- R6::R6Class(
332354
}
333355
differences <- names(new_hash)[!same]
334356

335-
cli::cli_abort(
336-
c(
337-
"{differences} {?does/do}n't match stored value{?s}.",
338-
i = "Do you need to pick a different {.arg path}?"
339-
),
340-
call = call
341-
)
357+
if (self$ignore_hash) {
358+
cli::cli_warn(
359+
c("!" = "{differences} {?does/do}n't match stored value{?s}."),
360+
call = call
361+
)
362+
} else {
363+
cli::cli_abort(
364+
c(
365+
"{differences} {?does/do}n't match stored value{?s}.",
366+
i = "Do you need to pick a different {.arg path}?",
367+
i = "Or set {.code ignore_hash = TRUE} to ignore this check?"
368+
),
369+
call = call
370+
)
371+
}
342372
}
343373
)
344374
)
345375

346376
provider_hash <- function(x) {
347-
props <- props(x)
348-
349-
# Backward compatible hashing after introduction of new properties
350-
if (length(props$extra_headers) == 0) {
351-
props$extra_headers <- NULL
352-
}
353-
props
377+
list(
378+
name = x@name,
379+
model = x@model,
380+
base_url = x@base_url
381+
)
354382
}
355383

356384
check_has_batch_support <- function(provider, call = caller_env()) {

man/batch_chat.Rd

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/_snaps/batch-chat.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Error in `batch_chat()`:
77
! provider, prompts, and user_turns don't match stored values.
88
i Do you need to pick a different `path`?
9+
i Or set `ignore_hash = TRUE` to ignore this check?
910

1011
---
1112

@@ -15,6 +16,15 @@
1516
Error in `batch_chat_structured()`:
1617
! provider, prompts, and user_turns don't match stored values.
1718
i Do you need to pick a different `path`?
19+
i Or set `ignore_hash = TRUE` to ignore this check?
20+
21+
# can override hash check
22+
23+
Code
24+
. <- batch_chat(chat, prompts, path, ignore_hash = TRUE)
25+
Condition
26+
Warning in `batch_chat()`:
27+
! prompts and user_turns don't match stored values.
1828

1929
# informative error for bad inputs
2030

tests/testthat/batch/state-capitals.json

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22
"version": 1,
33
"stage": "done",
44
"batch": {
5-
"id": "batch_6826575e8994819083aca31bb6d307de",
5+
"id": "batch_68f802ddb59481908fc70271e9eff0f6",
66
"object": "batch",
77
"endpoint": "/v1/chat/completions",
8+
"model": "gpt-4.1-nano-2025-04-14",
89
"errors": {},
9-
"input_file_id": "file-M1HWkC3RwUDPgaWtZYjjcy",
10+
"input_file_id": "file-JPA9WjqJ5dYmauWGMnEijt",
1011
"completion_window": "24h",
1112
"status": "completed",
12-
"output_file_id": "file-SdRQAoNeNzaFYr3YFb5hcp",
13+
"output_file_id": "file-5JPzmFxLH7MtjLzL2TpjWN",
1314
"error_file_id": {},
14-
"created_at": 1747343198,
15-
"in_progress_at": 1747343199,
16-
"expires_at": 1747429598,
17-
"finalizing_at": 1747343399,
18-
"completed_at": 1747343400,
15+
"created_at": 1761084125,
16+
"in_progress_at": 1761084128,
17+
"expires_at": 1761170525,
18+
"finalizing_at": 1761084149,
19+
"completed_at": 1761084152,
1920
"failed_at": {},
2021
"expired_at": {},
2122
"cancelling_at": {},
@@ -25,16 +26,27 @@
2526
"completed": 4,
2627
"failed": 0
2728
},
29+
"usage": {
30+
"input_tokens": 81,
31+
"output_tokens": 7,
32+
"total_tokens": 88,
33+
"input_tokens_details": {
34+
"cached_tokens": 0
35+
},
36+
"output_tokens_details": {
37+
"reasoning_tokens": 0
38+
}
39+
},
2840
"metadata": {}
2941
},
3042
"results": [
3143
{
3244
"status_code": 200,
33-
"request_id": "6c41082f8debff173736a0b52e07d948",
45+
"request_id": "98d76687bb4841866c5e3660bc7aec00",
3446
"body": {
35-
"id": "chatcmpl-BXa7llR1CJFH3KbFBLJf4ZvvYEUj4",
47+
"id": "chatcmpl-CTEikVO7HRPuFV6CqMKpuwCp0iSKN",
3648
"object": "chat.completion",
37-
"created": 1747343385,
49+
"created": 1761084134,
3850
"model": "gpt-4.1-nano-2025-04-14",
3951
"choices": [
4052
{
@@ -65,16 +77,16 @@
6577
}
6678
},
6779
"service_tier": "default",
68-
"system_fingerprint": "fp_eede8f0d45"
80+
"system_fingerprint": "fp_7c233bf9d1"
6981
}
7082
},
7183
{
7284
"status_code": 200,
73-
"request_id": "881526127f188f503beb9e98d7933d01",
85+
"request_id": "5c4a190dd9079087f0ee2d1cc6ecbbed",
7486
"body": {
75-
"id": "chatcmpl-BXa6CU6GypSA9ODF6TKhpkHPP8jlI",
87+
"id": "chatcmpl-CTEioj4PdLMO0B9dVarkET9n9cXzu",
7688
"object": "chat.completion",
77-
"created": 1747343288,
89+
"created": 1761084138,
7890
"model": "gpt-4.1-nano-2025-04-14",
7991
"choices": [
8092
{
@@ -105,16 +117,16 @@
105117
}
106118
},
107119
"service_tier": "default",
108-
"system_fingerprint": "fp_eede8f0d45"
120+
"system_fingerprint": "fp_f12167b370"
109121
}
110122
},
111123
{
112124
"status_code": 200,
113-
"request_id": "d35aa9cb5535dd1ea70e1c5e3c469791",
125+
"request_id": "dde5e47eb23802d49bbc415a2b76599e",
114126
"body": {
115-
"id": "chatcmpl-BXa6316yijIIu48fQeazyXljyjTXk",
127+
"id": "chatcmpl-CTEisUiHnFjkZp7qyVIt9icqbf7dk",
116128
"object": "chat.completion",
117-
"created": 1747343279,
129+
"created": 1761084142,
118130
"model": "gpt-4.1-nano-2025-04-14",
119131
"choices": [
120132
{
@@ -131,8 +143,8 @@
131143
],
132144
"usage": {
133145
"prompt_tokens": 20,
134-
"completion_tokens": 3,
135-
"total_tokens": 23,
146+
"completion_tokens": 2,
147+
"total_tokens": 22,
136148
"prompt_tokens_details": {
137149
"cached_tokens": 0,
138150
"audio_tokens": 0
@@ -145,16 +157,16 @@
145157
}
146158
},
147159
"service_tier": "default",
148-
"system_fingerprint": "fp_eede8f0d45"
160+
"system_fingerprint": "fp_f12167b370"
149161
}
150162
},
151163
{
152164
"status_code": 200,
153-
"request_id": "fb17b486b4053f56120614c6fbffe571",
165+
"request_id": "a905ebc6ccd6c29b1f5ee6d9d2839db4",
154166
"body": {
155-
"id": "chatcmpl-BXa6aPBtnciow6qc6gjEPMJ7DjgY7",
167+
"id": "chatcmpl-CTEivElBhxFZjwzSP0QqcnJoZQzGx",
156168
"object": "chat.completion",
157-
"created": 1747343312,
169+
"created": 1761084145,
158170
"model": "gpt-4.1-nano-2025-04-14",
159171
"choices": [
160172
{
@@ -171,8 +183,8 @@
171183
],
172184
"usage": {
173185
"prompt_tokens": 20,
174-
"completion_tokens": 2,
175-
"total_tokens": 22,
186+
"completion_tokens": 1,
187+
"total_tokens": 21,
176188
"prompt_tokens_details": {
177189
"cached_tokens": 0,
178190
"audio_tokens": 0
@@ -185,13 +197,13 @@
185197
}
186198
},
187199
"service_tier": "default",
188-
"system_fingerprint": "fp_eede8f0d45"
200+
"system_fingerprint": "fp_f12167b370"
189201
}
190202
}
191203
],
192-
"started_at": 1747343197,
204+
"started_at": 1761084124,
193205
"hash": {
194-
"provider": "de1de607a44b8964f051074a91a3787e",
206+
"provider": "959dc7923e4dc3760a7690794dc41c2a",
195207
"prompts": "b8eafe281e3cc5113058d9722be3e295",
196208
"user_turns": "8c1302088b9bc30258d1191db0d35705"
197209
}

0 commit comments

Comments
 (0)